Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
35c54e5
feat(chat): mark chats unread or read from the chat list
bmc08gt Nov 2, 2026
8799912
fix(chat): name group senders in the long-press preview and keep its …
bmc08gt Nov 2, 2026
a008b5b
fix(chat): swap the select circle in place and give the read swipe Mu…
bmc08gt Nov 2, 2026
5241121
fix(chat): keep each account's unread marks across logout
bmc08gt Nov 2, 2026
ae492a0
feat(chat): render the long-press preview with the chat's own transcript
bmc08gt Nov 2, 2026
2a60855
fix(chat): report menu and bulk unmute like the Mute sheet
bmc08gt Nov 2, 2026
4047adc
feat(chat): title every swipe action under its tonal pill
bmc08gt Nov 3, 2026
73937f9
test(chat): pin the short swipe titles beside the menu's long ones
bmc08gt Nov 3, 2026
79728cf
fix(members): title the remove swipe action "Remove"
bmc08gt Nov 3, 2026
8968acc
fix(members): put Remove at the outer edge of the member swipe
bmc08gt Nov 3, 2026
a802059
fix(members): use the speaker for the Restrict swipe
bmc08gt Nov 3, 2026
cbb60c3
fix(moderation): use the crossed-out mic for Restrict
bmc08gt Nov 3, 2026
fb93f7e
feat(chats): archive takes the trailing full swipe
bmc08gt Nov 3, 2026
a9ea9f5
fix(chats): slide the select circles in with the row content
bmc08gt Nov 3, 2026
dc560bb
fix(chats): keep the row's identity when select mode starts
bmc08gt Nov 3, 2026
a4aaf2a
fix(chats): lay the select row out from one animated progress
bmc08gt Nov 3, 2026
8ad402f
fix(chats): build the long-press preview only when it shows
bmc08gt Nov 3, 2026
91e422b
feat(chats): fade the select circle in place while the row slides
bmc08gt Nov 3, 2026
aef6fdc
feat(chats): frost the chat list behind a row's long-press preview
bmc08gt Nov 3, 2026
75b8600
fix(chats): fade the chat list's frost in instead of cutting to it
bmc08gt Nov 3, 2026
9349bfa
fix(chats): load photos in the chat list's long-press preview
bmc08gt Nov 3, 2026
e56be26
feat(chats): draw Mark as Read as a speech bubble with a check
bmc08gt Nov 4, 2026
9d97084
fix(chats): keep the bottom blur while swapping the tab bar for edit …
bmc08gt Nov 4, 2026
8e6298f
fix(chats): hide the tab bar in one frame when entering select mode
bmc08gt Nov 4, 2026
c0ec105
fix(chats): float the select-mode capsules over the list with no edge…
bmc08gt Nov 4, 2026
9859182
fix(chats): fade the tab bar for select mode so the bottom blur stays
bmc08gt Nov 4, 2026
79c20d0
Merge branch 'main' into feat/chat-mark-unread
bmc08gt Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 154 additions & 7 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ final class ConversationController {
var chatListProjection: ChatListProjection<ConversationID> {
// Reading the store's tracked set here is what makes archiving re-render the list.
let archived = chatArchive?.archivedIDs ?? []
// Same for the marks: reading the tracked set re-renders rows, chips and the badge.
let marked = chatMarkedUnread?.markedIDs ?? []
let entries = chatListSource.map { conversation in
ChatListEntry(
id: conversation.id,
Expand All @@ -111,7 +113,7 @@ final class ConversationController {
isArchived: archived.contains(conversation.id),
isMuted: conversation.isMuted(at: .now),
isHidden: conversation.isHidden,
unread: chatListUnread(for: conversation),
unread: chatListUnread(for: conversation, isMarked: marked.contains(conversation.id)),
joinRequests: chatListJoinRequests(for: conversation.id)
)
}
Expand All @@ -130,8 +132,14 @@ final class ConversationController {
return ChatListJoinRequests(count: requests.count, newestEnteredAt: requests.compactMap(\.enteredAt).max())
}

private func chatListUnread(for conversation: Conversation) -> ChatListUnread {
guard conversation.hasUnread(for: selfUserID) else { return .count(0) }
/// The unread state the list rules see for `conversation`, mark included.
func chatListUnread(for conversation: Conversation) -> ChatListUnread {
chatListUnread(for: conversation, isMarked: isMarkedUnread(conversation.id))
}

/// A marked chat with nothing unread is the no-count unread state; a known count wins over the mark.
private func chatListUnread(for conversation: Conversation, isMarked: Bool) -> ChatListUnread {
guard conversation.hasUnread(for: selfUserID) else { return isMarked ? .unknown : .count(0) }
return unreadCount(for: conversation).map(ChatListUnread.count) ?? .unknown
}

Expand Down Expand Up @@ -169,6 +177,102 @@ final class ConversationController {
chatArchive?.unarchive(id)
}

/// Archives every chat in `ids`.
func archive(_ ids: [ConversationID]) {
ids.forEach(archive)
}

/// Unarchives every chat in `ids`.
func unarchive(_ ids: [ConversationID]) {
ids.forEach(unarchive)
}

// MARK: - Mark unread

/// Whether the user marked the chat unread on this device.
func isMarkedUnread(_ id: ConversationID) -> Bool {
chatMarkedUnread?.markedIDs.contains(id) ?? false
}

/// Unread messages or a mark: what decides between Mark as Read and Mark as Unread. A pending
/// join request doesn't count; it is not something reading the chat clears.
func isUnread(_ conversation: Conversation) -> Bool {
conversation.hasUnread(for: selfUserID) || isMarkedUnread(conversation.id)
}

/// The read action a chat's swipe, menu and selection offer; nil for a chat the user hasn't
/// joined, such as one still in Requests, which can't be marked.
func readAction(for conversation: Conversation) -> ChatReadAction? {
guard !ChatListSelection.source(from: [conversation], isMember: store.isMember(of:)).isEmpty else { return nil }
return isUnread(conversation) ? .markRead : .markUnread
}

/// Runs `action` on one chat.
func perform(_ action: ChatReadAction, on id: ConversationID, source: ChatReadSource) {
switch action {
case .markRead: markRead(id, source: source)
case .markUnread: markUnread(id, source: source)
}
}

/// Marks the chat unread on this device. Sends nothing.
func markUnread(_ id: ConversationID, source: ChatReadSource) {
guard let conversation = conversation(withID: id), readAction(for: conversation) != nil else { return }
chatMarkedUnread?.mark(id)
}

/// Clears the mark and advances READ to the newest message, the same call opening the chat makes.
/// Other members see the receipt. A chat with nothing unread sends nothing.
func markRead(_ id: ConversationID, source: ChatReadSource) {
chatMarkedUnread?.clear(id)
guard let conversation = conversation(withID: id),
conversation.hasUnread(for: selfUserID),
let newest = conversation.lastMessage?.id
else { return }
advanceReadPointer(to: newest, in: id)
}

/// Read All: reads every unread chat in the main list, muted included, and clears every mark
/// there. Archived chats keep theirs. Returns how many chats it read, for analytics.
@discardableResult
func readAllChats() -> Int {
let unread = chatListConversations.filter(isUnread)
for conversation in unread {
markRead(conversation.id, source: .menu)
}
return unread.count
}

/// The select-mode read capsule: reads every chat in `ids`, or marks them all, per `toggle`.
/// Read All with nothing selected goes through ``readAllChats()`` after its confirmation instead.
func apply(_ toggle: ChatSelectionReadToggle, to ids: [ConversationID]) {
switch toggle {
case .readAll: return
case .read: ids.forEach { markRead($0, source: .selection) }
case .unread: ids.forEach { markUnread($0, source: .selection) }
}
}

/// The read capsule for a selection of `ids`.
func readToggle(for ids: [ConversationID]) -> ChatSelectionReadToggle {
ChatSelectionReadToggle(selectedUnread: ids.compactMap(conversation(withID:)).map(isUnread))
}

/// The mute capsule for a selection of `ids`.
func muteToggle(for ids: [ConversationID]) -> ChatSelectionMute {
ChatSelectionMute(selectedMuted: ids.map { isMuted(conversationID: $0) })
}

/// Whether Read All has anything to do.
var hasUnreadInChatList: Bool {
chatListConversations.contains(where: isUnread)
}

/// Called as a chat opens: opening it is reading it, so the mark goes.
func chatOpened(_ id: ConversationID) {
chatMarkedUnread?.clear(id)
}

/// The unread count a conversation row shows; nil when the chat is unread but its READ
/// watermark's message is neither stored nor fetched yet. See
/// ``Conversation/unreadCount(for:unreadSeqAt:)`` and ``resolveUnreadCount(for:)``.
Expand Down Expand Up @@ -415,6 +519,9 @@ final class ConversationController {
/// `chatDrafts`; tests attach one only when they exercise archive.
@ObservationIgnored var chatArchive: ChatArchiveStore?

/// The chats marked unread on this device, wired by `SessionAuthenticator` like ``chatArchive``.
@ObservationIgnored var chatMarkedUnread: (any ChatMarkedUnreadStore)?

@ObservationIgnored var chatDrafts: ChatDraftStore? {
didSet { failedSends = chatDrafts.map(FailedSendDrafts.init(store:)) }
}
Expand Down Expand Up @@ -1398,6 +1505,7 @@ final class ConversationController {
// The server clears archive with mute on leave, so the local record is stale the moment the
// leave lands. A hidden chat's record is deliberately left alone elsewhere (rule 5).
chatArchive?.unarchive(conversationID)
chatMarkedUnread?.clear(conversationID)
}

/// Records the signed-in user's own join or leave when a roster update names them, so a membership
Expand Down Expand Up @@ -1462,6 +1570,20 @@ final class ConversationController {
applyViewerState(viewerState, in: conversationID)
}

/// Mutes every chat in `ids` until `mute` lapses, one call each, stopping at the first failure.
func mute(_ ids: [ConversationID], _ mute: ConversationMuteState) async throws {
for id in ids {
try await self.mute(conversationID: id, mute)
}
}

/// Unmutes every chat in `ids`, one call each, stopping at the first failure.
func unmute(_ ids: [ConversationID]) async throws {
for id in ids {
try await unmute(conversationID: id)
}
}

/// Seats a viewer state the RPC returned through the same version comparison a streamed
/// `viewerStateChanged` goes through, so a response overtaken by the stream loses rather than
/// reinstating the state it already replaced.
Expand Down Expand Up @@ -1799,15 +1921,40 @@ final class ConversationController {
return "Typing…"
}
guard let message = conversation.lastMessage else { return nil }
return messagePreview(
message,
in: conversation,
knownAuthors: knownAuthors,
viewerAmount: viewerAmount,
requestTitle: requestTitle,
currencyName: currencyName
)
}

/// One message as a line of text, attributed to whoever wrote it: what the feed row shows for
/// the last message, and what the long-press preview shows for each of the last few.
func messagePreview(
_ message: ConversationMessage,
in conversation: Conversation,
knownAuthors: KnownAuthorDirectory.Snapshot = .empty,
viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount },
requestTitle: (PaymentRequestID) -> String? = { _ in nil },
attributed: Bool = true,
currencyName: (PublicKey) -> String?
) -> String? {
let isFromSelf = message.isFromSelf(selfUserID)
let senderName = groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors)
// Unattributed lines drop the "You:" / "Name:" prefix: the preview's bubble side and sender
// label already say who wrote them.
let senderName = attributed
? groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors)
: nil

switch message.content {
case .text(let text):
// A bare prefix says less than no line at all, so an empty body previews as nothing.
guard !text.isEmpty else { return nil }
let text = InviteLinkText.redacting(text)
if isFromSelf { return "You: \(text)" }
if isFromSelf && attributed { return "You: \(text)" }
guard let senderName else { return text }
return "\(senderName): \(text)"

Expand Down Expand Up @@ -1836,7 +1983,7 @@ final class ConversationController {

case .media(_, let caption):
let body = ChatMediaStrings.listPreview(caption: caption)
if isFromSelf { return "You: \(body)" }
if isFromSelf && attributed { return "You: \(body)" }
guard let senderName else { return body }
return "\(senderName): \(body)"

Expand Down Expand Up @@ -1926,7 +2073,7 @@ final class ConversationController {
/// attribution: the viewer's own message is covered by "You", and a DM's other party is what
/// the row is already titled after. A sender the chat's roster subset omits is named from
/// `knownAuthors`, and left unattributed until the directory has a name for them.
private func groupSenderName(
func groupSenderName(
for message: ConversationMessage,
in conversation: Conversation,
isFromSelf: Bool,
Expand Down
77 changes: 77 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// ChatMarkedUnreadStore.swift
// Flipcash
//

import Foundation
import Observation
import FlipcashCore

/// The chats the user has marked unread. Every caller reads and writes through this, so a
/// server-backed store can replace the local one without any caller changing.
///
/// `markedIDs` must be observable: the list projection reads it, and the rows, chips and tab badge
/// re-render when it changes.
@MainActor
protocol ChatMarkedUnreadStore: AnyObject {

/// The marked chats.
var markedIDs: Set<ConversationID> { get }

/// Marks one chat.
func mark(_ id: ConversationID)

/// Clears one chat's mark.
func clear(_ id: ConversationID)

/// Clears the mark on each of `ids`.
func clear(_ ids: Set<ConversationID>)
}

/// The device-only ``ChatMarkedUnreadStore``: a JSON file beside the drafts in Application Support,
/// owner-scoped like ``ChatArchiveStore``, so each account keeps its own marks across logout. Outside `FlipcashStore`, whose version bump deletes the
/// database, because nothing can re-fetch a mark until the server stores it.
@MainActor
@Observable
final class LocalChatMarkedUnreadStore: ChatMarkedUnreadStore {

private(set) var markedIDs: Set<ConversationID>

@ObservationIgnored private let url: URL

/// A file that cannot be read is treated as no marks; the user can mark again.
init(directory: URL, owner: PublicKey) {
self.url = directory.appendingPathComponent("flipcash-\(owner.base58)-marked-unread.json")
let keys = (try? Data(contentsOf: url)).flatMap { try? JSONDecoder().decode([String].self, from: $0) } ?? []
self.markedIDs = Set(keys.compactMap { Data(base64Encoded: $0).map(ConversationID.init(data:)) })
}

func mark(_ id: ConversationID) {
guard !markedIDs.contains(id) else { return }
markedIDs.insert(id)
write()
}

func clear(_ id: ConversationID) {
guard markedIDs.contains(id) else { return }
markedIDs.remove(id)
write()
}

func clear(_ ids: Set<ConversationID>) {
guard !markedIDs.isDisjoint(with: ids) else { return }
markedIDs.subtract(ids)
write()
}

/// Keys as ``ChatArchiveFile/key(_:)`` does: base64 of the id's bytes, which fits both the
/// 32-byte DM shape and a group's 16 bytes.
private func write() {
do {
let keys = markedIDs.map { $0.data.base64EncodedString() }.sorted()
try JSONEncoder().encode(keys).write(to: url, options: .atomic)
} catch {
ErrorReporting.captureError(error, reason: "Failed to write marked-unread chats")
}
}
}
36 changes: 36 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,13 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
var snapDistanceFromNewest: CGFloat? = nil
/// The latest request to land on a message, from a message link; each new token scrolls once.
var messageJump: MessageJump? = nil
/// Draws the transcript alone, for the chat list's long-press preview: no bar, no jump button,
/// no touches, opened at the newest message. Read reporting is the caller's to turn off, through
/// `reportsReads` and `onMessagesSeen`, as it is for the screen.
var isReadOnlyPreview = false

func makeUIViewController(context: Context) -> ChatScreenViewController {
if isReadOnlyPreview { return makePreviewController(context: context) }
let jump = JumpToBottomModel()
context.coordinator.jump = jump
let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator))
Expand Down Expand Up @@ -236,7 +241,38 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
return screen
}

/// The transcript with the same rows, cells and attribution as the screen, over a plain empty
/// bar the clip closes to nothing. Nothing in it takes a touch.
private func makePreviewController(context: Context) -> ChatScreenViewController {
let screen = ChatScreenViewController(bar: UIView())
screen.opensAtUnreadDivider = false
screen.authorAvatars = authorAvatars
screen.ownProfile = ownProfile
screen.linkCardSource = linkCardSource
screen.updateAction = updateAction
context.coordinator.mintMediaURL = mintMediaURL
context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt
screen.mediaURLResolver = context.coordinator.mediaURLResolver
screen.reactionStripEntries = reactionStripEntries
screen.onMessagesSeen = onMessagesSeen
screen.reportsReads = reportsReads
screen.loadViewIfNeeded()
screen.view.isUserInteractionEnabled = false
screen.setBarHeight(0, accessories: BarAccessories())
screen.update(items: items)
context.coordinator.screen = screen
context.coordinator.lastMessageID = lastMessageID(of: items)
return screen
}

func updateUIViewController(_ screen: ChatScreenViewController, context: Context) {
if isReadOnlyPreview {
screen.authorAvatars = authorAvatars
screen.onMessagesSeen = onMessagesSeen
screen.reportsReads = reportsReads
screen.update(items: items)
return
}
// Re-supply the bar with current inputs; SwiftUI diffs it, so the composer's draft and
// focus survive across updates.
context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator)
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,8 @@ struct ConversationScreen: View {
content
.task(id: conversationID) {
guard let conversationID else { return }
// Opening a chat is reading it, so a mark from the list goes, whatever the gate says.
conversationController.chatOpened(conversationID)
// Ensure the conversation metadata is in the store before the title, tip styling, and Send
// Cash target rely on it. The post-tip open (and any push/link that lands here before the
// feed or stream has the freshly-created chat) would otherwise render the unresolved
Expand Down
Loading
Loading