diff --git a/.agent-docs/2026-01-21-nudge-media-type-plan.md b/.agent-docs/2026-01-21-nudge-media-type-plan.md new file mode 100644 index 000000000..072811a69 --- /dev/null +++ b/.agent-docs/2026-01-21-nudge-media-type-plan.md @@ -0,0 +1,15 @@ +# Nudge media type plan (2026-01-21) + +## Goal +Move nudge detection to `mediaType = "nudge"` (no emoji-based detection), add InputMedia support, and keep DB changes minimal. + +## Steps +- [x] Review current nudge handling in server encode/send paths, proto, and Apple send flows. +- [x] Update proto InputMedia to include nudge and regenerate protocol outputs. +- [x] Update server schema + send/encode logic to set/use `mediaType = "nudge"` and drop emoji detection (incl. notifications). +- [x] Update Apple send path (NudgeButton + SendMessageTransaction helper) to send InputMedia.nudge while keeping text. +- [x] Update/add tests to ensure only `mediaType = "nudge"` triggers nudge handling. + +## Notes +- Keep message text as "👋" for UI display, but rely on mediaType for detection. +- Migration should only expand the existing mediaType enum/check. 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..40cb293c5 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,8 +23,18 @@ struct NotificationSettingsButton: View { } } } + .background( + NavigationLink( + destination: MentionsNotificationSettingsView( + disableDmNotifications: $notificationSettings.disableDmNotifications + ), + isActive: $showMentionsSettings + ) { + EmptyView() + } + .hidden() + ) - .presentationDetents([.medium]) .presentationDragIndicator(.visible) } } @@ -86,7 +97,7 @@ struct NotificationSettingsButton: View { onChange: { notificationSettings.mode = $0 close() - }, + } ) NotificationSettingsItem( @@ -136,48 +147,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/ProtocolHelpers/ProtocolMedia.swift b/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMedia.swift index 07c30ea26..31b1676bf 100644 --- a/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMedia.swift +++ b/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMedia.swift @@ -51,4 +51,10 @@ public extension InputMedia { }) } } + + static func fromNudge() -> InputMedia { + InputMedia.with { + $0.media = .nudge(.init()) + } + } } diff --git a/apple/InlineKit/Sources/InlineKit/Transactions2/SendMessageTransaction.swift b/apple/InlineKit/Sources/InlineKit/Transactions2/SendMessageTransaction.swift index de5bb2a89..f436769e7 100644 --- a/apple/InlineKit/Sources/InlineKit/Transactions2/SendMessageTransaction.swift +++ b/apple/InlineKit/Sources/InlineKit/Transactions2/SendMessageTransaction.swift @@ -24,6 +24,7 @@ public struct SendMessageTransaction: Transaction2 { public var chatId: Int64 public var replyToMsgId: Int64? public var isSticker: Bool? + public var isNudge: Bool public var entities: MessageEntities? public var sendMode: MessageSendMode? public var randomId: Int64 @@ -35,6 +36,7 @@ public struct SendMessageTransaction: Transaction2 { case chatId case replyToMsgId case isSticker + case isNudge case entities case sendMode case randomId @@ -47,6 +49,7 @@ public struct SendMessageTransaction: Transaction2 { chatId: Int64, replyToMsgId: Int64?, isSticker: Bool?, + isNudge: Bool, entities: MessageEntities?, sendMode: MessageSendMode?, randomId: Int64, @@ -57,6 +60,7 @@ public struct SendMessageTransaction: Transaction2 { self.chatId = chatId self.replyToMsgId = replyToMsgId self.isSticker = isSticker + self.isNudge = isNudge self.entities = entities self.sendMode = sendMode self.randomId = randomId @@ -70,6 +74,7 @@ public struct SendMessageTransaction: Transaction2 { chatId = try container.decode(Int64.self, forKey: .chatId) replyToMsgId = try container.decodeIfPresent(Int64.self, forKey: .replyToMsgId) isSticker = try container.decodeIfPresent(Bool.self, forKey: .isSticker) + isNudge = try container.decodeIfPresent(Bool.self, forKey: .isNudge) ?? false entities = try container.decodeIfPresent(MessageEntities.self, forKey: .entities) if let rawValue = try container.decodeIfPresent(Int.self, forKey: .sendMode) { sendMode = MessageSendMode(rawValue: rawValue) ?? .modeUnspecified @@ -87,6 +92,9 @@ public struct SendMessageTransaction: Transaction2 { try container.encode(chatId, forKey: .chatId) try container.encodeIfPresent(replyToMsgId, forKey: .replyToMsgId) try container.encodeIfPresent(isSticker, forKey: .isSticker) + if isNudge { + try container.encode(isNudge, forKey: .isNudge) + } try container.encodeIfPresent(entities, forKey: .entities) if let sendMode = sendMode { try container.encode(sendMode.rawValue, forKey: .sendMode) @@ -102,6 +110,7 @@ public struct SendMessageTransaction: Transaction2 { chatId: Int64, replyToMsgId: Int64? = nil, isSticker: Bool? = nil, + isNudge: Bool = false, entities: MessageEntities? = nil, sendMode: MessageSendMode? = nil ) { @@ -112,6 +121,7 @@ public struct SendMessageTransaction: Transaction2 { chatId: chatId, replyToMsgId: replyToMsgId, isSticker: isSticker, + isNudge: isNudge, entities: entities, sendMode: sendMode, randomId: randomId, @@ -130,6 +140,7 @@ public struct SendMessageTransaction: Transaction2 { if let replyToMsgId = context.replyToMsgId { $0.replyToMsgID = replyToMsgId } if let entities = context.entities { $0.entities = entities } if let sendMode = context.sendMode { $0.sendMode = sendMode } + if context.isNudge { $0.media = InputMedia.fromNudge() } }) } @@ -294,6 +305,7 @@ public extension Transaction2 where Self == SendMessageTransaction { chatId: Int64, replyToMsgId: Int64? = nil, isSticker: Bool? = nil, + isNudge: Bool = false, entities: MessageEntities? = nil, sendMode: MessageSendMode? = nil ) -> SendMessageTransaction { @@ -303,6 +315,7 @@ public extension Transaction2 where Self == SendMessageTransaction { chatId: chatId, replyToMsgId: replyToMsgId, isSticker: isSticker, + isNudge: isNudge, entities: entities, sendMode: sendMode ) 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..a828b47e7 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 { @@ -3800,12 +3831,21 @@ public struct InputMedia: Sendable { set {media = .document(newValue)} } + public var nudge: InputMediaNudge { + get { + if case .nudge(let v)? = media {return v} + return InputMediaNudge() + } + set {media = .nudge(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum OneOf_Media: Equatable, Sendable { case photo(InputMediaPhoto) case video(InputMediaVideo) case document(InputMediaDocument) + case nudge(InputMediaNudge) } @@ -3851,6 +3891,17 @@ public struct InputMediaDocument: Sendable { public init() {} } +/// Nudge message (empty payload) +public struct InputMediaNudge: 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 SendMessageInput: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -8116,6 +8167,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 +8215,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 +8251,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 +8375,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 +10875,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 +10889,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 +10915,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 +10927,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 } @@ -11492,6 +11586,7 @@ extension InputMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio 1: .same(proto: "photo"), 2: .same(proto: "video"), 3: .same(proto: "document"), + 4: .same(proto: "nudge"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -11539,6 +11634,19 @@ extension InputMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio self.media = .document(v) } }() + case 4: try { + var v: InputMediaNudge? + 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 } } @@ -11562,6 +11670,10 @@ extension InputMedia: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio 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) @@ -11670,6 +11782,25 @@ extension InputMediaDocument: SwiftProtobuf.Message, SwiftProtobuf._MessageImple } } +extension InputMediaNudge: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "InputMediaNudge" + 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: InputMediaNudge, rhs: InputMediaNudge) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension SendMessageInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "SendMessageInput" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 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..882c054fe 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,6 @@ struct NotificationSettingsButton: View { popover .padding(.vertical, 10) .padding(.horizontal, 8) - .frame(width: 320) } } @@ -45,6 +45,9 @@ struct NotificationSettingsButton: View { if customizingZen { customize .transition(.opacity) + } else if customizingMentions { + mentionsCustomize + .transition(.opacity) } else { picker .transition(.opacity) @@ -100,6 +103,12 @@ struct NotificationSettingsButton: View { notificationSettings.mode = $0 }, + customizeAction: { + withAnimation(.easeOut(duration: 0.2)) { + customizingZen = false + customizingMentions = true + } + } ) NotificationSettingsItem( @@ -114,6 +123,7 @@ struct NotificationSettingsButton: View { customizeAction: { // Customize action for Zen Mode withAnimation(.easeOut(duration: 0.2)) { + customizingMentions = false customizingZen = true } } @@ -162,37 +172,93 @@ 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) // Done button at the bottom - Button(action: { - withAnimation(.easeOut(duration: 0.2)) { - customizingZen = false - } - }) { + HStack { Spacer() - Text("Done") - .font(.body.weight(.bold)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .cornerRadius(8) + Button("Done") { + withAnimation(.easeOut(duration: 0.2)) { + customizingZen = false + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + + @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) + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Disable DM notifications") + .font(.subheadline) + Text("Mentions and nudges will still notify you") + .font(.caption) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 12) + + Toggle("", isOn: $notificationSettings.disableDmNotifications) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel("Disable DM notifications") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + + + HStack { Spacer() + Button("Done") { + withAnimation(.easeOut(duration: 0.2)) { + customizingMentions = false + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) } - .buttonStyle(.borderedProminent) .padding(.horizontal, 8) .padding(.top, 8) } @@ -245,6 +311,8 @@ private struct NotificationSettingsItem: View { .font(.caption) .foregroundStyle(.tertiary) .padding(.top, -1) + .lineLimit(1) + } Spacer() if let customizeAction { diff --git a/apple/InlineUI/Sources/InlineUI/NudgeButton.swift b/apple/InlineUI/Sources/InlineUI/NudgeButton.swift new file mode 100644 index 000000000..5f1a46fb9 --- /dev/null +++ b/apple/InlineUI/Sources/InlineUI/NudgeButton.swift @@ -0,0 +1,198 @@ +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") + + 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 { + NudgeButtonState.attentionTarget(displayName: peerDisplayName) + } + + private func refreshPeerName(userId: Int64) { + peerDisplayName = ObjectCache.shared.getUser(id: userId)?.user.displayName + } + + private func handleTap() { + triggerHaptic() + if !hasSeenGuide { + hasSeenGuide = true + showGuide = true + return + } + + showGuide = false + 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: NudgeButtonState.nudgeText, + peerId: peer, + chatId: resolvedChatId, + replyToMsgId: nil, + isSticker: nil, + isNudge: true, + 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 + } +} + +enum NudgeButtonState { + static let nudgeText = "👋" + + static func attentionTarget(displayName: String?) -> String { + if let name = displayName, !name.isEmpty { + return "\(name)'s" + } + + return "their" + } +} + +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) + .buttonBorderShape(.capsule) + #endif + } + .padding(.top, 4) + } + .padding(12) + .frame(maxWidth: 260) + } +} + +#Preview { + NudgeButton(peer: .user(id: 1)) +} diff --git a/apple/InlineUI/Sources/Translation/Views/TranslationPopover.swift b/apple/InlineUI/Sources/Translation/Views/TranslationPopover.swift index 07138d638..bfb1b7892 100644 --- a/apple/InlineUI/Sources/Translation/Views/TranslationPopover.swift +++ b/apple/InlineUI/Sources/Translation/Views/TranslationPopover.swift @@ -55,6 +55,7 @@ public struct TranslationPopover: View { #if os(macOS) .buttonStyle(.bordered) .controlSize(.regular) + .buttonBorderShape(.capsule) #endif } else { Button("Translate") { @@ -63,6 +64,7 @@ public struct TranslationPopover: View { #if os(macOS) .buttonStyle(.borderedProminent) .controlSize(.regular) + .buttonBorderShape(.capsule) #endif } } @@ -76,6 +78,7 @@ public struct TranslationPopover: View { #if os(macOS) .buttonStyle(.bordered) .controlSize(.regular) + .buttonBorderShape(.capsule) .focusEffectDisabled(true) #endif } diff --git a/apple/InlineUI/Tests/InlineUITests/NudgeButtonTests.swift b/apple/InlineUI/Tests/InlineUITests/NudgeButtonTests.swift new file mode 100644 index 000000000..57e75b204 --- /dev/null +++ b/apple/InlineUI/Tests/InlineUITests/NudgeButtonTests.swift @@ -0,0 +1,22 @@ +import Testing + +@testable import InlineUI + +@Suite("NudgeButton") +struct NudgeButtonTests { + @Test("Uses default attention target when name is missing") + func attentionTargetFallsBack() async throws { + #expect(NudgeButtonState.attentionTarget(displayName: nil) == "their") + #expect(NudgeButtonState.attentionTarget(displayName: "") == "their") + } + + @Test("Uses possessive form for attention target when name is present") + func attentionTargetUsesName() async throws { + #expect(NudgeButtonState.attentionTarget(displayName: "Riley") == "Riley's") + } + + @Test("Uses the expected nudge text") + func nudgeTextConstant() async throws { + #expect(NudgeButtonState.nudgeText == "👋") + } +} diff --git a/proto/core.proto b/proto/core.proto index 32b1b8412..8ae77c4d3 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; } @@ -940,6 +947,7 @@ message InputMedia { InputMediaPhoto photo = 1; InputMediaVideo video = 2; InputMediaDocument document = 3; + InputMediaNudge nudge = 4; } } @@ -958,6 +966,9 @@ message InputMediaDocument { int64 document_id = 1; } +// Nudge message (empty payload) +message InputMediaNudge {} + message SendMessageInput { InputPeer peer_id = 1; diff --git a/server/packages/protocol/src/core.ts b/server/packages/protocol/src/core.ts index b2079efa8..6d2b3f84b 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 @@ -2490,6 +2509,12 @@ export interface InputMedia { * @generated from protobuf field: InputMediaDocument document = 3; */ document: InputMediaDocument; + } | { + oneofKind: "nudge"; + /** + * @generated from protobuf field: InputMediaNudge nudge = 4; + */ + nudge: InputMediaNudge; } | { oneofKind: undefined; }; @@ -2527,6 +2552,13 @@ export interface InputMediaDocument { */ documentId: bigint; } +/** + * Nudge message (empty payload) + * + * @generated from protobuf message InputMediaNudge + */ +export interface InputMediaNudge { +} /** * @generated from protobuf message SendMessageInput */ @@ -6528,7 +6560,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 +6594,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 +6621,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 +6773,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