From eab8eb3e565c596dc5556199b2981fc8beaa0400 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:54:20 +0330 Subject: [PATCH 1/9] add nudge --- apple/InlineIOS/Features/Chat/ChatView.swift | 27 ++- apple/InlineIOS/Localizable.xcstrings | 12 ++ .../UI/NotificationSettingsPopover.swift | 126 +++++++++--- .../Sources/InlineKit/Models/Message.swift | 2 + .../UserSettings/INUserSettings.swift | 1 + .../UserSettings/NotificationSettings.swift | 16 +- .../Sources/InlineProtocol/core.pb.swift | 74 +++++++ .../MainWindow/MainWindowController.swift | 19 +- .../Toolbar/Nudge/NudgeToolbar.swift | 20 ++ .../NotificationSettingsPopover.swift | 93 +++++++-- .../Sources/InlineUI/NudgeButton.swift | 188 ++++++++++++++++++ proto/core.proto | 7 + server/packages/protocol/src/core.ts | 65 +++++- server/src/db/models/userSettings/types.ts | 3 + server/src/functions/messages.sendMessage.ts | 41 +++- .../realtime/decoders/decodeUserSettings.ts | 1 + server/src/realtime/encoders/encodeMessage.ts | 28 +++ .../realtime/encoders/encodeUserSettings.ts | 1 + web/packages/protocol/src/core.ts | 65 +++++- 19 files changed, 732 insertions(+), 57 deletions(-) create mode 100644 apple/InlineMac/Toolbar/Nudge/NudgeToolbar.swift create mode 100644 apple/InlineUI/Sources/InlineUI/NudgeButton.swift diff --git a/apple/InlineIOS/Features/Chat/ChatView.swift b/apple/InlineIOS/Features/Chat/ChatView.swift index d08f6847d..a28c7f669 100644 --- a/apple/InlineIOS/Features/Chat/ChatView.swift +++ b/apple/InlineIOS/Features/Chat/ChatView.swift @@ -68,9 +68,30 @@ struct ChatView: View { .toolbar(.hidden, for: .tabBar) .toolbarRole(.editor) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - TranslationButton(peer: peerId) - .tint(ThemeManager.shared.accentColor) + if peerId.isPrivate { + if #available(iOS 26.0, *) { + ToolbarItem(placement: .primaryAction) { + NudgeButton(peer: peerId, chatId: fullChatViewModel.chat?.id) + } + ToolbarSpacer(.fixed, placement: .primaryAction) + ToolbarItem(placement: .primaryAction) { + TranslationButton(peer: peerId) + .tint(ThemeManager.shared.accentColor) + } + } else { + ToolbarItem(placement: .topBarTrailing) { + NudgeButton(peer: peerId, chatId: fullChatViewModel.chat?.id) + } + ToolbarItem(placement: .primaryAction) { + TranslationButton(peer: peerId) + .tint(ThemeManager.shared.accentColor) + } + } + } else { + ToolbarItem(placement: .primaryAction) { + TranslationButton(peer: peerId) + .tint(ThemeManager.shared.accentColor) + } } if #available(iOS 26.0, *) { diff --git a/apple/InlineIOS/Localizable.xcstrings b/apple/InlineIOS/Localizable.xcstrings index 9faded51c..fa77db41a 100644 --- a/apple/InlineIOS/Localizable.xcstrings +++ b/apple/InlineIOS/Localizable.xcstrings @@ -392,6 +392,9 @@ } } } + }, + "Control DM notification behavior" : { + }, "Control how you receive notifications" : { @@ -477,9 +480,15 @@ } } } + }, + "Direct Messages" : { + }, "Direct updates applied" : { + }, + "Disable DM notifications" : { + }, "Disconnect" : { @@ -726,6 +735,9 @@ }, "Mark Unread" : { + }, + "Mentions and nudges will still notify you" : { + }, "name@example.com" : { "comment" : "Email placeholder", diff --git a/apple/InlineIOS/UI/NotificationSettingsPopover.swift b/apple/InlineIOS/UI/NotificationSettingsPopover.swift index 74a7e61fe..deacae714 100644 --- a/apple/InlineIOS/UI/NotificationSettingsPopover.swift +++ b/apple/InlineIOS/UI/NotificationSettingsPopover.swift @@ -6,6 +6,7 @@ struct NotificationSettingsButton: View { @EnvironmentObject private var notificationSettings: NotificationSettingsManager @State private var presented = false + @State private var showMentionsSettings = false var body: some View { button @@ -22,6 +23,17 @@ struct NotificationSettingsButton: View { } } } + .background( + NavigationLink( + destination: MentionsNotificationSettingsView( + disableDmNotifications: $notificationSettings.disableDmNotifications + ), + isActive: $showMentionsSettings + ) { + EmptyView() + } + .hidden() + ) .presentationDetents([.medium]) .presentationDragIndicator(.visible) @@ -86,7 +98,7 @@ struct NotificationSettingsButton: View { onChange: { notificationSettings.mode = $0 close() - }, + } ) NotificationSettingsItem( @@ -136,48 +148,96 @@ private struct NotificationSettingsItem: View { var selected: Bool var value: Value var onChange: (Value) -> Void + var customizeAction: (() -> Void)? let theme = ThemeManager.shared.selected var body: some View { - Button { + HStack(spacing: 12) { + Circle() + .fill(selected ? Color(theme.accent) : Color(.systemGray5)) + .frame(width: 36, height: 36) + .overlay { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(selected ? Color.white : Color(.systemGray)) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body) + .fontWeight(.medium) + + Text(description) + .font(.caption) + + } + + Spacer() + + if let customizeAction { + Button(action: customizeAction) { + Circle() + .frame(width: 28, height: 28) + .foregroundStyle(Color(.systemGray5)) + .overlay { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + } + + if selected { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(theme.accent)) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(selected ? Color(theme.accent).opacity(0.1) : ThemeManager.shared.cardBackgroundColor) + ) + .contentShape(Rectangle()) + .onTapGesture { onChange(value) - } label: { - HStack(spacing: 12) { - Circle() - .fill(selected ? Color(theme.accent) : Color(.systemGray5)) - .frame(width: 36, height: 36) - .overlay { - Image(systemName: systemImage) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(selected ? Color.white : Color(.systemGray)) - } + } + .animation(.easeOut(duration: 0.08), value: selected) + } +} + +private struct MentionsNotificationSettingsView: View { + @Binding var disableDmNotifications: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Direct Messages") + .font(.headline) + Text("Control DM notification behavior") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Toggle(isOn: $disableDmNotifications) { VStack(alignment: .leading, spacing: 2) { - Text(title) + Text("Disable DM notifications") .font(.body) - .fontWeight(.medium) - - Text(description) + Text("Mentions and nudges will still notify you") .font(.caption) - - } - - Spacer() - - if selected { - Image(systemName: "checkmark") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Color(theme.accent)) + .foregroundStyle(.secondary) } } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(selected ? Color(theme.accent).opacity(0.1) : ThemeManager.shared.cardBackgroundColor) - ) + .toggleStyle(.switch) + + Spacer() } - .buttonStyle(.plain) - .animation(.easeOut(duration: 0.08), value: selected) + .padding(16) + .navigationTitle("Direct Messages") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index 6f39a9e93..7c1836039 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -502,6 +502,8 @@ public extension InlineProtocol.Message { message } else if isSticker == true { "🖼️ Sticker" + } else if case .nudge = media.media { + "👋 Nudge" } else if media.photo.hasPhoto { "🖼️ Photo" } else if media.video.hasVideo { diff --git a/apple/InlineKit/Sources/InlineKit/UserSettings/INUserSettings.swift b/apple/InlineKit/Sources/InlineKit/UserSettings/INUserSettings.swift index f06ad4d33..44ff0880c 100644 --- a/apple/InlineKit/Sources/InlineKit/UserSettings/INUserSettings.swift +++ b/apple/InlineKit/Sources/InlineKit/UserSettings/INUserSettings.swift @@ -65,6 +65,7 @@ public class INUserSettings { // Update current settings with cached values notification.mode = cachedSettings.mode notification.silent = cachedSettings.silent + notification.disableDmNotifications = cachedSettings.disableDmNotifications notification.requiresMention = cachedSettings.requiresMention notification.usesDefaultRules = cachedSettings.usesDefaultRules notification.customRules = cachedSettings.customRules diff --git a/apple/InlineKit/Sources/InlineKit/UserSettings/NotificationSettings.swift b/apple/InlineKit/Sources/InlineKit/UserSettings/NotificationSettings.swift index f433bce80..712748351 100644 --- a/apple/InlineKit/Sources/InlineKit/UserSettings/NotificationSettings.swift +++ b/apple/InlineKit/Sources/InlineKit/UserSettings/NotificationSettings.swift @@ -6,6 +6,7 @@ import InlineProtocol public class NotificationSettingsManager: ObservableObject, Codable, @unchecked Sendable { @Published public var mode: NotificationMode @Published public var silent: Bool + @Published public var disableDmNotifications: Bool @Published public var requiresMention: Bool @Published public var usesDefaultRules: Bool @Published public var customRules: String @@ -14,6 +15,7 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked // Initialize with default values mode = .all silent = false + disableDmNotifications = false requiresMention = true usesDefaultRules = true customRules = "" @@ -32,6 +34,12 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked silent = from.silent + if from.hasDisableDmNotifications { + disableDmNotifications = from.disableDmNotifications + } else { + disableDmNotifications = false + } + if from.hasZenModeRequiresMention { requiresMention = from.zenModeRequiresMention } else { @@ -60,6 +68,9 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked } silent = from.silent + if from.hasDisableDmNotifications { + disableDmNotifications = from.disableDmNotifications + } if from.hasZenModeRequiresMention { requiresMention = from.zenModeRequiresMention } @@ -80,6 +91,7 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked case .none: .none } $0.silent = silent + $0.disableDmNotifications = disableDmNotifications $0.zenModeRequiresMention = requiresMention $0.zenModeUsesDefaultRules = usesDefaultRules $0.zenModeCustomRules = customRules @@ -89,7 +101,7 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked // MARK: - Codable Implementation private enum CodingKeys: String, CodingKey { - case mode, silent, requiresMention, usesDefaultRules, customRules + case mode, silent, disableDmNotifications, requiresMention, usesDefaultRules, customRules } public required init(from decoder: Decoder) throws { @@ -97,6 +109,7 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked mode = try container.decode(NotificationMode.self, forKey: .mode) silent = try container.decode(Bool.self, forKey: .silent) + disableDmNotifications = try container.decode(Bool.self, forKey: .disableDmNotifications) requiresMention = try container.decode(Bool.self, forKey: .requiresMention) usesDefaultRules = try container.decode(Bool.self, forKey: .usesDefaultRules) customRules = try container.decode(String.self, forKey: .customRules) @@ -107,6 +120,7 @@ public class NotificationSettingsManager: ObservableObject, Codable, @unchecked try container.encode(mode, forKey: .mode) try container.encode(silent, forKey: .silent) + try container.encode(disableDmNotifications, forKey: .disableDmNotifications) try container.encode(requiresMention, forKey: .requiresMention) try container.encode(usesDefaultRules, forKey: .usesDefaultRules) try container.encode(customRules, forKey: .customRules) diff --git a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift index 91eaad29f..ee9015caa 100644 --- a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift +++ b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift @@ -1854,12 +1854,21 @@ public struct MessageMedia: Sendable { set {media = .document(newValue)} } + public var nudge: MessageNudge { + get { + if case .nudge(let v)? = media {return v} + return MessageNudge() + } + set {media = .nudge(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum OneOf_Media: Equatable, Sendable { case photo(MessagePhoto) case video(MessageVideo) case document(MessageDocument) + case nudge(MessageNudge) } @@ -1929,6 +1938,17 @@ public struct MessageDocument: Sendable { fileprivate var _document: Document? = nil } +/// Nudge message (empty payload) +public struct MessageNudge: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Video: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -3368,6 +3388,16 @@ public struct NotificationSettings: Sendable { /// Clears the value of `zenModeCustomRules`. Subsequent reads from it will return its default value. public mutating func clearZenModeCustomRules() {self._zenModeCustomRules = nil} + /// If true, direct message notifications are disabled + public var disableDmNotifications: Bool { + get {return _disableDmNotifications ?? false} + set {_disableDmNotifications = newValue} + } + /// Returns true if `disableDmNotifications` has been explicitly set. + public var hasDisableDmNotifications: Bool {return self._disableDmNotifications != nil} + /// Clears the value of `disableDmNotifications`. Subsequent reads from it will return its default value. + public mutating func clearDisableDmNotifications() {self._disableDmNotifications = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum Mode: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -3423,6 +3453,7 @@ public struct NotificationSettings: Sendable { fileprivate var _zenModeRequiresMention: Bool? = nil fileprivate var _zenModeUsesDefaultRules: Bool? = nil fileprivate var _zenModeCustomRules: String? = nil + fileprivate var _disableDmNotifications: Bool? = nil } public struct UpdateUserSettingsInput: Sendable { @@ -8116,6 +8147,7 @@ extension MessageMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat 1: .same(proto: "photo"), 2: .same(proto: "video"), 3: .same(proto: "document"), + 4: .same(proto: "nudge"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -8163,6 +8195,19 @@ extension MessageMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat self.media = .document(v) } }() + case 4: try { + var v: MessageNudge? + var hadOneofValue = false + if let current = self.media { + hadOneofValue = true + if case .nudge(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.media = .nudge(v) + } + }() default: break } } @@ -8186,6 +8231,10 @@ extension MessageMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat guard case .document(let v)? = self.media else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 3) }() + case .nudge?: try { + guard case .nudge(let v)? = self.media else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -8306,6 +8355,25 @@ extension MessageDocument: SwiftProtobuf.Message, SwiftProtobuf._MessageImplemen } } +extension MessageNudge: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "MessageNudge" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: MessageNudge, rhs: MessageNudge) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Video: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "Video" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -10787,6 +10855,7 @@ extension NotificationSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImp 3: .standard(proto: "zen_mode_requires_mention"), 4: .standard(proto: "zen_mode_uses_default_rules"), 5: .standard(proto: "zen_mode_custom_rules"), + 6: .standard(proto: "disable_dm_notifications"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -10800,6 +10869,7 @@ extension NotificationSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImp case 3: try { try decoder.decodeSingularBoolField(value: &self._zenModeRequiresMention) }() case 4: try { try decoder.decodeSingularBoolField(value: &self._zenModeUsesDefaultRules) }() case 5: try { try decoder.decodeSingularStringField(value: &self._zenModeCustomRules) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self._disableDmNotifications) }() default: break } } @@ -10825,6 +10895,9 @@ extension NotificationSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImp try { if let v = self._zenModeCustomRules { try visitor.visitSingularStringField(value: v, fieldNumber: 5) } }() + try { if let v = self._disableDmNotifications { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -10834,6 +10907,7 @@ extension NotificationSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if lhs._zenModeRequiresMention != rhs._zenModeRequiresMention {return false} if lhs._zenModeUsesDefaultRules != rhs._zenModeUsesDefaultRules {return false} if lhs._zenModeCustomRules != rhs._zenModeCustomRules {return false} + if lhs._disableDmNotifications != rhs._disableDmNotifications {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/apple/InlineMac/Features/MainWindow/MainWindowController.swift b/apple/InlineMac/Features/MainWindow/MainWindowController.swift index 6099ca61e..83dc1f78e 100644 --- a/apple/InlineMac/Features/MainWindow/MainWindowController.swift +++ b/apple/InlineMac/Features/MainWindow/MainWindowController.swift @@ -429,6 +429,7 @@ extension LegacyMainWindowController: NSToolbarDelegate { .chatTitle, .participants, .space, + .nudge, .translate, ] } @@ -494,6 +495,10 @@ extension LegacyMainWindowController: NSToolbarDelegate { guard case let .chat(peer) = nav.currentRoute else { return nil } return createTranslateButton(peer: peer) + case .nudge: + guard case let .chat(peer) = nav.currentRoute, case .user = peer else { return nil } + return createNudgeButton(peer: peer) + default: return nil } @@ -520,6 +525,12 @@ extension LegacyMainWindowController: NSToolbarDelegate { return item } + private func createNudgeButton(peer: Peer) -> NSToolbarItem { + let item = NudgeToolbar(peer: peer, dependencies: dependencies) + item.visibilityPriority = .high + return item + } + @objc private func createNewSpace() { dependencies.nav.open(.createSpace) } @@ -577,9 +588,14 @@ extension LegacyMainWindowController { items.append(.participants) } - // Add space between participants and translate + // Add space between participants and actions items.append(.space) + if case .user = peer { + items.append(.nudge) + items.append(.space) + } + // FIXME: check if we should show this items.append(.translate) @@ -681,6 +697,7 @@ extension NSToolbarItem.Identifier { static let navForward = Self("NavForward") static let chatTitle = Self("ChatTitle") static let participants = Self("Participants") + static let nudge = Self("Nudge") static let translate = Self("Translate") static let transparentItem = Self("TransparentItem") static let textItem = Self("TextItem") diff --git a/apple/InlineMac/Toolbar/Nudge/NudgeToolbar.swift b/apple/InlineMac/Toolbar/Nudge/NudgeToolbar.swift new file mode 100644 index 000000000..bd15ed343 --- /dev/null +++ b/apple/InlineMac/Toolbar/Nudge/NudgeToolbar.swift @@ -0,0 +1,20 @@ +import AppKit +import InlineKit +import InlineUI +import SwiftUI + +class NudgeToolbar: NSToolbarItem { + private var peer: Peer + private var dependencies: AppDependencies + + init(peer: Peer, dependencies: AppDependencies) { + self.peer = peer + self.dependencies = dependencies + super.init(itemIdentifier: .nudge) + + visibilityPriority = .low + + let hostingView = NSHostingView(rootView: NudgeButton(peer: peer)) + view = hostingView + } +} diff --git a/apple/InlineMac/Views/NotificationSettingsPopover/NotificationSettingsPopover.swift b/apple/InlineMac/Views/NotificationSettingsPopover/NotificationSettingsPopover.swift index 70f3dd442..ea0ff0c92 100644 --- a/apple/InlineMac/Views/NotificationSettingsPopover/NotificationSettingsPopover.swift +++ b/apple/InlineMac/Views/NotificationSettingsPopover/NotificationSettingsPopover.swift @@ -7,6 +7,7 @@ struct NotificationSettingsButton: View { @State private var presented = false @State private var customizingZen = false + @State private var customizingMentions = false var body: some View { button @@ -14,7 +15,7 @@ struct NotificationSettingsButton: View { popover .padding(.vertical, 10) .padding(.horizontal, 8) - .frame(width: 320) + .frame(width: 320, height: 300, alignment: .top) } } @@ -45,6 +46,9 @@ struct NotificationSettingsButton: View { if customizingZen { customize .transition(.opacity) + } else if customizingMentions { + mentionsCustomize + .transition(.opacity) } else { picker .transition(.opacity) @@ -100,6 +104,12 @@ struct NotificationSettingsButton: View { notificationSettings.mode = $0 }, + customizeAction: { + withAnimation(.easeOut(duration: 0.2)) { + customizingZen = false + customizingMentions = true + } + } ) NotificationSettingsItem( @@ -114,6 +124,7 @@ struct NotificationSettingsButton: View { customizeAction: { // Customize action for Zen Mode withAnimation(.easeOut(duration: 0.2)) { + customizingMentions = false customizingZen = true } } @@ -162,18 +173,29 @@ struct NotificationSettingsButton: View { .font(.subheadline) .foregroundStyle(.secondary) - TextEditor( - text: notificationSettings.usesDefaultRules ? .constant(defaultRules) : $notificationSettings - .customRules - ) - .font(.body) - .foregroundStyle(notificationSettings.usesDefaultRules ? .secondary : .primary) - .frame(height: 100) - .padding(.horizontal, 6) - .padding(.vertical, 6) - .scrollContentBackground(.hidden) - .background(.secondary.opacity(0.2)) - .cornerRadius(10) + if notificationSettings.usesDefaultRules { + ScrollView { + Text(defaultRules) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 100) + .padding(.horizontal, 6) + .padding(.vertical, 6) + .background(.secondary.opacity(0.2)) + .cornerRadius(10) + } else { + TextEditor(text: $notificationSettings.customRules) + .font(.body) + .foregroundStyle(.primary) + .frame(height: 100) + .padding(.horizontal, 6) + .padding(.vertical, 6) + .scrollContentBackground(.hidden) + .background(.secondary.opacity(0.2)) + .cornerRadius(10) + } }.padding(.horizontal, 8) @@ -198,6 +220,51 @@ struct NotificationSettingsButton: View { } } + @ViewBuilder + private var mentionsCustomize: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 0) { + Text("Messages to you") + .font(.headline) + } + }.padding(.horizontal, 6) + + Divider().foregroundStyle(.tertiary) + .padding(.vertical, 6) + + Toggle(isOn: $notificationSettings.disableDmNotifications) { + VStack(alignment: .leading, spacing: 2) { + Text("Disable DM notifications") + .font(.subheadline) + Text("Mentions and nudges will still notify you") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .toggleStyle(.switch) + .padding(.horizontal, 8) + + Button(action: { + withAnimation(.easeOut(duration: 0.2)) { + customizingMentions = false + } + }) { + Spacer() + Text("Done") + .font(.body.weight(.bold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .cornerRadius(8) + Spacer() + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + let defaultRules = """ - Something urgent has came up (eg. a bug or an incident). - I must wake up for something. diff --git a/apple/InlineUI/Sources/InlineUI/NudgeButton.swift b/apple/InlineUI/Sources/InlineUI/NudgeButton.swift new file mode 100644 index 000000000..de3b4b100 --- /dev/null +++ b/apple/InlineUI/Sources/InlineUI/NudgeButton.swift @@ -0,0 +1,188 @@ +import GRDB +import InlineKit +import Logger +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// Nudge button used in iOS nav bar and macOS toolbar for DMs. +public struct NudgeButton: View { + private let log = Log.scoped("NudgeButton") + private let nudgeText = "👋" + + public let peer: Peer + public let chatId: Int64? + + @AppStorage("nudgeGuideSeen", store: UserDefaults.shared) private var hasSeenGuide = false + @State private var showGuide = false + @State private var isSending = false + @State private var peerDisplayName: String? = nil + + public init(peer: Peer, chatId: Int64? = nil) { + self.peer = peer + self.chatId = chatId + } + + public var body: some View { + if let userId = peerUserId { + content + .onAppear { + refreshPeerName(userId: userId) + } + .onReceive(ObjectCache.shared.getUserPublisher(id: userId)) { userInfo in + peerDisplayName = userInfo?.user.displayName + } + } else { + content + } + } + + @ViewBuilder + private var content: some View { + Button { + handleTap() + } label: { + Image(systemName: "hand.wave") + .font(.system(size: 16, weight: .regular)) + } + .accessibilityLabel("Send nudge") + .disabled(isSending) + .popover(isPresented: $showGuide, arrowEdge: .bottom) { + NudgeGuideView(attentionTarget: attentionTarget) { + showGuide = false + } + .presentationCompactAdaptation(.popover) + } + } + + private var peerUserId: Int64? { + if case let .user(id) = peer { + return id + } + return nil + } + + private var attentionTarget: String { + if let name = peerDisplayName, !name.isEmpty { + return "\(name)'s" + } + return "their" + } + + private func refreshPeerName(userId: Int64) { + peerDisplayName = ObjectCache.shared.getUser(id: userId)?.user.displayName + } + + private func handleTap() { + triggerHaptic() + if !hasSeenGuide { + hasSeenGuide = true + showGuide = false + return + } + + showGuide = true + sendNudge() + } + + private func sendNudge() { + guard !isSending else { return } + + isSending = true + + Task { + defer { + Task { @MainActor in + isSending = false + } + } + + guard let resolvedChatId = await resolveChatId() else { + log.error("Unable to resolve chatId for nudge for peer \(peer)") + return + } + + do { + _ = try await Api.realtime.send( + .sendMessage( + text: nudgeText, + peerId: peer, + chatId: resolvedChatId, + replyToMsgId: nil, + isSticker: nil, + entities: nil, + sendMode: nil + ) + ) + } catch { + log.error("Failed to send nudge", error: error) + } + } + } + + private func resolveChatId() async -> Int64? { + if let chatId, chatId > 0 { + return chatId + } + + do { + return try await AppDatabase.shared.dbWriter.read { db in + let dialogId = Dialog.getDialogId(peerId: peer) + let dialog = try Dialog.fetchOne(db, id: dialogId) + return dialog?.chatId + } + } catch { + log.error("Failed to resolve chatId", error: error) + return nil + } + } + + private func triggerHaptic() { +#if os(iOS) + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() +#elseif os(macOS) + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) +#endif + } +} + +private struct NudgeGuideView: View { + let attentionTarget: String + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Nudge") + .font(.headline) + + Text("Send a 👋 to get \(attentionTarget) attention. Nudges trigger a notification even if their notifications are off.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + Button("Got it") { + onDismiss() + } + #if os(macOS) + .buttonStyle(.borderedProminent) + .controlSize(.regular) + #endif + } + .padding(.top, 4) + } + .padding(12) + .frame(maxWidth: 260) + } +} + +#Preview { + NudgeButton(peer: .user(id: 1)) +} diff --git a/proto/core.proto b/proto/core.proto index 32b1b8412..e64cddcfc 100644 --- a/proto/core.proto +++ b/proto/core.proto @@ -435,6 +435,7 @@ message MessageMedia { MessagePhoto photo = 1; MessageVideo video = 2; MessageDocument document = 3; + MessageNudge nudge = 4; } } @@ -444,6 +445,9 @@ message MessageVideo { Video video = 1; } message MessageDocument { Document document = 1; } +// Nudge message (empty payload) +message MessageNudge {} + message Video { int64 id = 1; @@ -836,6 +840,9 @@ message NotificationSettings { // Custom rules for notifications optional string zen_mode_custom_rules = 5; + + // If true, direct message notifications are disabled + optional bool disable_dm_notifications = 6; } message UpdateUserSettingsInput { UserSettings user_settings = 1; } diff --git a/server/packages/protocol/src/core.ts b/server/packages/protocol/src/core.ts index b2079efa8..bc023714f 100644 --- a/server/packages/protocol/src/core.ts +++ b/server/packages/protocol/src/core.ts @@ -1129,6 +1129,12 @@ export interface MessageMedia { * @generated from protobuf field: MessageDocument document = 3; */ document: MessageDocument; + } | { + oneofKind: "nudge"; + /** + * @generated from protobuf field: MessageNudge nudge = 4; + */ + nudge: MessageNudge; } | { oneofKind: undefined; }; @@ -1160,6 +1166,13 @@ export interface MessageDocument { */ document?: Document; } +/** + * Nudge message (empty payload) + * + * @generated from protobuf message MessageNudge + */ +export interface MessageNudge { +} /** * @generated from protobuf message Video */ @@ -2195,6 +2208,12 @@ export interface NotificationSettings { * @generated from protobuf field: optional string zen_mode_custom_rules = 5; */ zenModeCustomRules?: string; + /** + * If true, direct message notifications are disabled + * + * @generated from protobuf field: optional bool disable_dm_notifications = 6; + */ + disableDmNotifications?: boolean; } /** * @generated from protobuf enum NotificationSettings.Mode @@ -6528,7 +6547,8 @@ class MessageMedia$Type extends MessageType { super("MessageMedia", [ { no: 1, name: "photo", kind: "message", oneof: "media", T: () => MessagePhoto }, { no: 2, name: "video", kind: "message", oneof: "media", T: () => MessageVideo }, - { no: 3, name: "document", kind: "message", oneof: "media", T: () => MessageDocument } + { no: 3, name: "document", kind: "message", oneof: "media", T: () => MessageDocument }, + { no: 4, name: "nudge", kind: "message", oneof: "media", T: () => MessageNudge } ]); } create(value?: PartialMessage): MessageMedia { @@ -6561,6 +6581,12 @@ class MessageMedia$Type extends MessageType { document: MessageDocument.internalBinaryRead(reader, reader.uint32(), options, (message.media as any).document) }; break; + case /* MessageNudge nudge */ 4: + message.media = { + oneofKind: "nudge", + nudge: MessageNudge.internalBinaryRead(reader, reader.uint32(), options, (message.media as any).nudge) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -6582,6 +6608,9 @@ class MessageMedia$Type extends MessageType { /* MessageDocument document = 3; */ if (message.media.oneofKind === "document") MessageDocument.internalBinaryWrite(message.media.document, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + /* MessageNudge nudge = 4; */ + if (message.media.oneofKind === "nudge") + MessageNudge.internalBinaryWrite(message.media.nudge, writer.tag(4, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -6731,6 +6760,31 @@ class MessageDocument$Type extends MessageType { */ export const MessageDocument = new MessageDocument$Type(); // @generated message type with reflection information, may provide speed optimized methods +class MessageNudge$Type extends MessageType { + constructor() { + super("MessageNudge", []); + } + create(value?: PartialMessage): MessageNudge { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MessageNudge): MessageNudge { + return target ?? this.create(); + } + internalBinaryWrite(message: MessageNudge, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MessageNudge + */ +export const MessageNudge = new MessageNudge$Type(); +// @generated message type with reflection information, may provide speed optimized methods class Video$Type extends MessageType