mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-15 07:06:11 +00:00
Mark a DM unread when sent while the app was closed
A DM sent while the app is closed reaches the Nostr overload of handlePrivateMessage through the gift-wrap lookback, so it is minutes or hours old by the time it is handled. Gating the unread badge on a 30 second recency window meant the message was appended to the conversation with nothing showing that it arrived, findable only by opening that chat. The gate predates the persistent gift-wrap dedup from #1398. Before that store existed, every relaunch replayed the lookback, so recency kept replayed DMs from re-badging and re-alerting on every launch. #1398 now drops replayed events before they reach this code, leaving the gate suppressing only first-time deliveries. The mesh overload in the same file has no recency test: if the chat is not on screen, the conversation is marked unread. Recency now gates the notification instead, so a reconnect that pulls a batch of old DMs still does not fire a burst of alerts. Full iOS suite green; swiftlint adds no violations.
This commit is contained in:
parent
9edb7c26ef
commit
9c8db0873e
@ -548,8 +548,14 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
let isViewing = context.selectedPrivateChatPeer == conversationPeerID
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
// Recency gates only the notification. A DM sent while the app was
|
||||
// closed reaches this path through the gift-wrap lookback long after
|
||||
// it was sent, so gating the badge on recency left the conversation
|
||||
// looking read and the message findable only by opening that chat.
|
||||
// The mesh overload below marks unread whenever the chat is not on
|
||||
// screen, with no recency test at all.
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing
|
||||
if shouldMarkUnread {
|
||||
context.markPrivateChatUnread(conversationPeerID)
|
||||
}
|
||||
@ -558,7 +564,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
}
|
||||
|
||||
if !isViewing && shouldMarkUnread {
|
||||
if shouldMarkUnread && isRecentMessage {
|
||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID)
|
||||
}
|
||||
|
||||
|
||||
@ -460,7 +460,8 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
|
||||
let payloadData = PrivateMessagePacket(messageID: "geo-1", content: "hi there").encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
// Old timestamp: not "recent", so no unread marking (and no notification).
|
||||
// An old timestamp suppresses the notification but not the unread
|
||||
// badge: recency gates only the alert.
|
||||
let oldTimestamp = Date().addingTimeInterval(-120)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
@ -476,7 +477,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.sentGeoDeliveryAcks == ["geo-1"])
|
||||
#expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"])
|
||||
#expect(context.privateChats[convKey]?.first?.sender == "bob#5678")
|
||||
#expect(context.unreadPrivateMessages.isEmpty)
|
||||
#expect(context.unreadPrivateMessages == [convKey])
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
|
||||
// Redelivery: ack is deduplicated and the message is not appended twice.
|
||||
@ -491,6 +492,91 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.privateChats[convKey]?.count == 1)
|
||||
}
|
||||
|
||||
/// A DM sent while the app was closed reaches this path through the
|
||||
/// gift-wrap lookback, so it is minutes or hours old by the time it is
|
||||
/// handled. The conversation still has to show as unread or the message
|
||||
/// is unfindable; only the notification is gated on recency.
|
||||
@Test @MainActor
|
||||
func geoPrivateMessage_marksUnreadForAnOldMessageWithoutNotifying() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let convKey = PeerID(str: "nostr_abcdef12")
|
||||
let senderPubkey = "feedface00112233"
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
|
||||
let payloadData = PrivateMessagePacket(
|
||||
messageID: "offline-1",
|
||||
content: "sent while you were away"
|
||||
).encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date().addingTimeInterval(-3600)
|
||||
)
|
||||
|
||||
#expect(context.privateChats[convKey]?.map(\.id) == ["offline-1"])
|
||||
#expect(context.unreadPrivateMessages == [convKey])
|
||||
#expect(context.privateMessageNotifications.isEmpty)
|
||||
}
|
||||
|
||||
/// The recency window still does its job: a message that arrives while
|
||||
/// the person is elsewhere in the app both badges and notifies.
|
||||
@Test @MainActor
|
||||
func geoPrivateMessage_notifiesForARecentMessage() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let convKey = PeerID(str: "nostr_abcdef12")
|
||||
let senderPubkey = "feedface00112233"
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
|
||||
let payloadData = PrivateMessagePacket(
|
||||
messageID: "fresh-1",
|
||||
content: "just sent"
|
||||
).encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.unreadPrivateMessages == [convKey])
|
||||
#expect(context.privateMessageNotifications.map(\.peerID) == [convKey])
|
||||
}
|
||||
|
||||
/// Opening the conversation is what clears it: a message that arrives
|
||||
/// while the chat is on screen is neither badged nor notified.
|
||||
@Test @MainActor
|
||||
func geoPrivateMessage_doesNotMarkUnreadWhileTheChatIsOpen() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let convKey = PeerID(str: "nostr_abcdef12")
|
||||
let senderPubkey = "feedface00112233"
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
|
||||
context.selectedPrivateChatPeer = convKey
|
||||
let payloadData = PrivateMessagePacket(
|
||||
messageID: "viewing-1",
|
||||
content: "you are looking at this"
|
||||
).encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.unreadPrivateMessages.isEmpty)
|
||||
#expect(context.privateMessageNotifications.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user