diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 628920a8..d697e0ed 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -999,22 +999,6 @@ public enum L10n { return L10n.tr("Chats", "chats.scrollButton.scrollToBottom.accessibilityValue", p1, fallback: "%d unread messages") } } - public enum ScrollToDivider { - /// Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button - public static let accessibilityHint = L10n.tr("Chats", "chats.scrollButton.scrollToDivider.accessibilityHint", fallback: "Double-tap to scroll to the new messages divider") - /// Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button - public static let accessibilityLabel = L10n.tr("Chats", "chats.scrollButton.scrollToDivider.accessibilityLabel", fallback: "Scroll to new messages") - } - public enum ScrollToMention { - /// Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button - public static let accessibilityHint = L10n.tr("Chats", "chats.scrollButton.scrollToMention.accessibilityHint", fallback: "Double-tap to navigate to the message") - /// Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button - public static let accessibilityLabel = L10n.tr("Chats", "chats.scrollButton.scrollToMention.accessibilityLabel", fallback: "Scroll to your oldest unread mention") - /// Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions - public static func accessibilityValue(_ p1: Int) -> String { - return L10n.tr("Chats", "chats.scrollButton.scrollToMention.accessibilityValue", p1, fallback: "%d unread mentions") - } - } } public enum Search { /// Location: ChatsView.swift - Search placeholder diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 70dfa6b1..28ce4e26 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Zur ältesten ungelesenen Erwähnung scrollen"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Doppeltippen, um zur Nachricht zu navigieren"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d ungelesene Erwähnungen"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Zur neuesten Nachricht scrollen"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Zu neuen Nachrichten scrollen"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Doppeltippen, um zur Trennlinie der neuen Nachrichten zu scrollen"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -809,7 +794,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Pfad"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Pfadkarte"; @@ -916,7 +900,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Fertig"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index 33175785..331a1e0f 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Scroll to your oldest unread mention"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Double-tap to navigate to the message"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d unread mentions"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Scroll to latest message"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Scroll to new messages"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Double-tap to scroll to the new messages divider"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -908,7 +893,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Path"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Path Map"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 8781e911..f53dd861 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Ir a tu mención más antigua sin leer"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Toca dos veces para navegar al mensaje"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d menciones sin leer"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Ir al mensaje más reciente"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Ir a los mensajes nuevos"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Pulsa dos veces para ir al divisor de mensajes nuevos"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -809,7 +794,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Ruta"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Mapa de ruta"; @@ -916,7 +900,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Listo"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index d3b0e7d5..7016d13c 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Aller à votre plus ancienne mention non lue"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Appuyez deux fois pour accéder au message"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d mentions non lues"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Aller au dernier message"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Faire défiler jusqu'aux nouveaux messages"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Appuyez deux fois pour faire défiler jusqu'au séparateur de nouveaux messages"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -809,7 +794,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Chemin"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Carte du trajet"; @@ -916,7 +900,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Terminé"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index bd66fff7..8d64fe93 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Scroll naar je oudste ongelezen vermelding"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Dubbeltik om naar het bericht te navigeren"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d ongelezen vermeldingen"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Scroll naar nieuwste bericht"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Scroll naar nieuwe berichten"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Dubbeltik om naar de scheidingslijn voor nieuwe berichten te scrollen"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -809,7 +794,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Pad"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Padkaart"; @@ -916,7 +900,6 @@ /* Location: Various - Done button */ "chats.common.done" = "Klaar"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 1e7fa9ee..55b31886 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Przewiń do najstarszej nieprzeczytanej wzmianki"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Dotknij dwukrotnie, aby przejść do wiadomości"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d nieprzeczytanych wzmianek"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Przewiń do najnowszej wiadomości"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Przewiń do nowych wiadomości"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Stuknij dwukrotnie, aby przewinąć do separatora nowych wiadomości"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -804,7 +789,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Ścieżka"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Mapa trasy"; @@ -908,7 +892,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Gotowe"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index 8bd669bc..ac42de47 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -109,15 +109,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Перейти к самому старому непрочитанному упоминанию"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Двойное нажатие для перехода к сообщению"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d непрочитанных упоминаний"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Перейти к последнему сообщению"; @@ -126,12 +117,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Прокрутить к новым сообщениям"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Нажмите дважды, чтобы прокрутить к разделителю новых сообщений"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -803,7 +788,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Путь"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Карта пути"; @@ -907,7 +891,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Готово"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index e7e0f255..dc77a24e 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -109,15 +109,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "Перейти до найстарішої непрочитаної згадки"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "Подвійний дотик для переходу до повідомлення"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d непрочитаних згадок"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "Перейти до останнього повідомлення"; @@ -126,12 +117,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "Перейти до нових повідомлень"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "Подвійний дотик для переходу до роздільника нових повідомлень"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -803,7 +788,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "Шлях"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "Карта шляху"; @@ -907,7 +891,6 @@ /* Location: Various - Done button (use L10n.Localizable.Common.done) */ "chats.common.done" = "Готово"; - // MARK: - Message Path /* Direct message path (no hops) */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 1c4abc8b..0f8c3f08 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -110,15 +110,6 @@ // MARK: - Scroll Buttons -/* Location: ScrollToMentionButton.swift - Accessibility label for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityLabel" = "滚动到最早的未读提及"; - -/* Location: ScrollToMentionButton.swift - Accessibility hint for scroll to mention button */ -"chats.scrollButton.scrollToMention.accessibilityHint" = "双击以导航到消息"; - -/* Location: ScrollToMentionButton.swift - Accessibility value for unread mention count - %d is the number of unread mentions */ -"chats.scrollButton.scrollToMention.accessibilityValue" = "%d条未读提及"; - /* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ "chats.scrollButton.scrollToBottom.accessibilityLabel" = "滚动到最新消息"; @@ -127,12 +118,6 @@ /* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ "chats.scrollButton.badge.overflow" = "99+"; -/* Location: ScrollToDividerButton.swift - Accessibility label for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityLabel" = "滚动到新消息"; - -/* Location: ScrollToDividerButton.swift - Accessibility hint for scroll to divider button */ -"chats.scrollButton.scrollToDivider.accessibilityHint" = "双击以滚动到新消息分隔符"; - // MARK: - Chat View (Direct Messages) /* Location: ChatConversationView.swift - Alert title when message send fails */ @@ -904,7 +889,6 @@ /* Location: MessagePathSheet.swift - Section header for path */ "chats.path.section.header" = "路径"; - /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ "chats.path.map" = "路径地图"; diff --git a/MC1/Services/InlineImagePrefetcher.swift b/MC1/Services/InlineImagePrefetcher.swift index 4e5fc274..0192ac4e 100644 --- a/MC1/Services/InlineImagePrefetcher.swift +++ b/MC1/Services/InlineImagePrefetcher.swift @@ -67,11 +67,18 @@ final class InlineImagePrefetcher { } } else { group.addTask { - _ = await linkPreviewCache.preview( + let result = await linkPreviewCache.preview( for: url, using: dataStore, isChannelMessage: isChannelMessage ) + // Persist the hero aspect under the requested page URL (the key + // the build path looks up) so the loading shimmer reserves the + // final card footprint on the next build. + if case let .loaded(dto) = result, + let width = dto.imageWidth, let height = dto.imageHeight { + await dimensionsStore.save(url: url, size: CGSize(width: width, height: height)) + } } } } diff --git a/MC1/State/AppState+ChatPrefetch.swift b/MC1/State/AppState+ChatPrefetch.swift new file mode 100644 index 00000000..53934367 --- /dev/null +++ b/MC1/State/AppState+ChatPrefetch.swift @@ -0,0 +1,204 @@ +import Foundation +import MC1Services +import SwiftUI + +/// The view-only environment values `AppState.chatEnvInputs` needs, captured +/// on each call so background coordinator refreshes can rebuild `EnvInputs` +/// for any conversation without a view in hand. +struct ChatEnvSnapshot { + var themeID: String + var isDark: Bool + var isHighContrast: Bool + var contentSizeCategory: String +} + +extension AppState { + /// Builds the dependency bundle a chat `ChatViewModel` needs. Shared by the + /// live conversation view and the navigation-time prefetch so the two cannot + /// drift into wiring different services. + @MainActor + func makeChatViewModelDependencies() -> ChatViewModel.Dependencies { + ChatViewModel.Dependencies( + dataStore: { self.offlineDataStore }, + messageService: { self.services?.messageService }, + notificationService: { self.services?.notificationService }, + channelService: { self.services?.channelService }, + roomServerService: { self.services?.roomServerService }, + contactService: { self.services?.contactService }, + syncCoordinator: { self.syncCoordinator }, + connectionState: { self.connectionState }, + connectedDevice: { self.connectedDevice }, + currentRadioID: { self.currentRadioID }, + session: { self.services?.session }, + reactionService: { self.services?.reactionService }, + chatSendQueueService: { self.services?.chatSendQueueService }, + inlineImageDimensionsStore: { self.services?.inlineImageDimensionsStore }, + prefetchDataStore: { self.services?.dataStore } + ) + } + + /// Builds the `EnvInputs` snapshot the chat view model bakes into `MessageItem`s. + /// Reads the user-preference toggles from `UserDefaults.standard` (matching the + /// `@AppStorage` reads at render time) and takes the four true-environment values + /// that only exist inside a SwiftUI view. Shared by `ChatConversationView` and the + /// navigation-time prefetch so a prefetched timeline bakes identical items and the + /// on-open rebuild is a no-op swap rather than a content flash. + @MainActor + func chatEnvInputs( + for conversation: ChatConversationType?, + themeID: String, + isDark: Bool, + isHighContrast: Bool, + contentSizeCategory: String + ) -> EnvInputs { + lastChatEnvSnapshot = ChatEnvSnapshot( + themeID: themeID, + isDark: isDark, + isHighContrast: isHighContrast, + contentSizeCategory: contentSizeCategory + ) + + let defaults = UserDefaults.standard + func bool(_ key: AppStorageKey, _ fallback: Bool) -> Bool { + defaults.object(forKey: key.rawValue) as? Bool ?? fallback + } + + let showMapPreviews = bool(.showMapPreviewThumbnails, AppStorageKey.defaultShowMapPreviewThumbnails) + && !(conversation?.suppressesMapPreviews ?? false) + + return EnvInputs( + autoPlayGIFs: bool(.autoPlayGIFs, AppStorageKey.defaultAutoPlayGIFs), + showIncomingPath: bool(.showIncomingPath, AppStorageKey.defaultShowIncomingPath), + showIncomingHopCount: bool(.showIncomingHopCount, AppStorageKey.defaultShowIncomingHopCount), + showIncomingRegion: bool(.showIncomingRegion, AppStorageKey.defaultShowIncomingRegion), + showIncomingSendTime: bool(.showIncomingSendTime, AppStorageKey.defaultShowIncomingSendTime), + previewsEnabled: bool(.linkPreviewsEnabled, AppStorageKey.defaultLinkPreviewsEnabled), + isHighContrast: isHighContrast, + isDark: isDark, + showMapPreviews: showMapPreviews, + isOffline: !offlineMapService.isNetworkAvailable, + currentUserName: localNodeName, + themeID: themeID, + contentSizeCategory: contentSizeCategory + ) + } + + /// Warms the shared `ChatCoordinator` for `conversation` before its view is + /// pushed, so the conversation renders populated on the first frame instead of + /// popping in a frame after the push transition (the cold-open jump). The + /// coordinator persists in the registry, so a re-open reuses the warm entry — + /// this no-ops when it is already loaded. + /// + /// Fire-and-forget on a throwaway view model: only the shared coordinator (held + /// by the registry) outlives the prime, and `ChatConversationView` rebinds it on + /// open. The prime deliberately skips notification suppression, unread clearing, + /// and flood-scope pushes — those belong to the real open, not a speculative warm. + @MainActor + func prefetchConversation(_ conversation: ChatConversationType, envInputs: EnvInputs) { + guard let registry = ensureChatCoordinatorRegistry() else { return } + + // Already open: the live view owns this timeline; a speculative warm + // has nothing to add and its `.prime` bind would be denied anyway. + if isConversationOpen(conversation) { return } + + // Already warm: the registry entry survives from a prior prefetch or open. + if let existing = registry.existingCoordinator(for: conversation.coordinatorID), + existing.renderState.phase == .loaded { + return + } + + let viewModel = ChatViewModel() + Task { @MainActor in + // `.prime` binds a revocable writer: if the user opens this chat + // mid-prime, the live view's `.interactive` bind revokes it and every + // remaining write from this view model no-ops at the coordinator. A + // nil writer means the open already happened, so abort the prime. + viewModel.configure( + dependencies: self.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: self.backgroundLinkPreviewCache, + chatCoordinatorRegistry: registry, + conversation: conversation, + role: .prime + ) + guard viewModel.timelineWriter != nil else { return } + viewModel.applyEnvInputs(envInputs) + + switch conversation { + case let .dm(contact): + await viewModel.primeInitialMessages(for: contact) + case let .channel(channel): + // Contacts first so contactNameSet is populated before buildChannelSenders runs. + await viewModel.loadAllContacts(radioID: channel.radioID) + await viewModel.primeInitialChannelMessages(for: channel) + } + // Warm preview metadata and hero dimensions for the primed tail so the + // open builds cards synchronously at their final height. + await viewModel.prewarmRecentPreviews() + } + } + + /// Whether the conversation is currently on screen, per the notification + /// service's active-slot bookkeeping (the same signal `ChatPrewarmRefresher` + /// consults before an arrival-time refresh). + @MainActor + private func isConversationOpen(_ conversation: ChatConversationType) -> Bool { + guard let notificationService = services?.notificationService else { return false } + switch conversation { + case let .dm(contact): + return notificationService.activeContactID == contact.id + case let .channel(channel): + return notificationService.activeChannelIndex == channel.index + && notificationService.activeChannelRadioID == channel.radioID + } + } + + /// Lazily builds the refresher that re-primes warm coordinators when + /// messages arrive for closed conversations (see `ChatPrewarmRefresher`). + /// Every hook resolves through `self` weakly at call time, so the refresher + /// stays valid across reconnects and registry rebinds. + func ensureChatPrewarmRefresher() -> ChatPrewarmRefresher { + if let chatPrewarmRefresher { return chatPrewarmRefresher } + let refresher = ChatPrewarmRefresher(hooks: ChatPrewarmRefresher.Hooks( + registry: { [weak self] in + self?.ensureChatCoordinatorRegistry() + }, + dependencies: { [weak self] in + self?.makeChatViewModelDependencies() + }, + envInputs: { [weak self] conversation in + guard let self, let snapshot = lastChatEnvSnapshot else { return nil } + return chatEnvInputs( + for: conversation, + themeID: snapshot.themeID, + isDark: snapshot.isDark, + isHighContrast: snapshot.isHighContrast, + contentSizeCategory: snapshot.contentSizeCategory + ) + }, + isConversationActive: { [weak self] kind in + guard let notificationService = self?.services?.notificationService else { return false } + switch kind { + case let .dm(contact): + return notificationService.activeContactID == contact.id + case let .channel(radioID, channelIndex): + return notificationService.activeChannelIndex == channelIndex + && notificationService.activeChannelRadioID == radioID + } + }, + channel: { [weak self] radioID, channelIndex in + guard let store = self?.offlineDataStore else { return nil } + return await (try? store.fetchChannel(radioID: radioID, index: channelIndex)) ?? nil + }, + contact: { [weak self] _, contactID in + guard let store = self?.offlineDataStore else { return nil } + return await (try? store.fetchContact(id: contactID)) ?? nil + }, + linkPreviewCache: { [weak self] in + self?.backgroundLinkPreviewCache + } + )) + chatPrewarmRefresher = refresher + return refresher + } +} diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index 5d2b25c1..eacef1cb 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -140,6 +140,21 @@ final class AppState { /// torn down on services-left. private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? + /// Re-primes warm chat coordinators when messages arrive for closed + /// conversations, so a reopen renders the fresh tail on the first frame. + /// Built lazily by `ensureChatPrewarmRefresher()`. + @ObservationIgnored var chatPrewarmRefresher: ChatPrewarmRefresher? + + /// Latest view-environment values seen by `chatEnvInputs`, reused when a + /// background coordinator refresh must bake items with no view in hand. + @ObservationIgnored var lastChatEnvSnapshot: ChatEnvSnapshot? + + /// Link-preview cache for the background prime paths (navigation prefetch, + /// arrival-time refresh), which run without a view's environment. Instance + /// identity is not load-bearing: durable preview state lives in the DB tier + /// and the shared decoded caches, same as the `\.linkPreviewCache` default. + @ObservationIgnored lazy var backgroundLinkPreviewCache: any LinkPreviewCaching = LinkPreviewCache() + /// Cached standalone persistence store for offline browsing private var cachedOfflineStore: PersistenceStore? diff --git a/MC1/State/ChatPrewarmRefresher.swift b/MC1/State/ChatPrewarmRefresher.swift new file mode 100644 index 00000000..61116a51 --- /dev/null +++ b/MC1/State/ChatPrewarmRefresher.swift @@ -0,0 +1,156 @@ +import Foundation +import MC1Services + +/// Re-primes warm `ChatCoordinator`s when a message arrives for a conversation +/// that is not currently open. +/// +/// A coordinator prewarmed by `AppState.prefetchConversation` keeps the tail it +/// had at prime time: nothing updates it while its chat is closed, because the +/// message event stream only feeds the open conversation's view model. +/// Reopening then renders the stale list on the first frame, and the fresh +/// fetch lands in the tiled list as an offset-preserving tail append, leaving +/// the view scrolled above the new messages instead of at the bottom. +/// Re-priming at arrival time keeps the warm tail current, so a reopen's first +/// frame is already complete and bottom-anchored. +/// +/// Owned by `AppState` (see `ensureChatPrewarmRefresher`), fed by +/// `MessageEventDispatcher`. Rooms have no coordinator and need no refresh. +@MainActor +final class ChatPrewarmRefresher { + /// Identifies the conversation a message event belongs to, carrying just + /// enough to key the coordinator lookup up front and resolve the full + /// conversation lazily after the debounce. + enum ConversationKind { + case dm(contact: ContactDTO) + case channel(radioID: UUID, channelIndex: UInt8) + + var coordinatorID: ChatConversationID { + switch self { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(radioID, channelIndex): + .channel(radioID: radioID, channelIndex: channelIndex) + } + } + } + + struct Hooks { + /// Registry holding the warm coordinators; nil while no store is available. + var registry: @MainActor () -> ChatCoordinatorRegistry? + /// Dependency bundle for the throwaway priming view model; nil when the + /// owner is gone. + var dependencies: @MainActor () -> ChatViewModel.Dependencies? + /// Environment snapshot to bake items with; nil when no chat UI has + /// rendered yet (nothing can be warm then, so skipping is safe). + var envInputs: @MainActor (ChatConversationType) -> EnvInputs? + /// Whether the conversation is currently open. The open view model already + /// appends arrivals in place; re-priming under it would race its + /// optimistic rows. + var isConversationActive: @MainActor (ConversationKind) -> Bool + /// Resolves the channel DTO for a channel event's radio + slot index. + var channel: @MainActor (UUID, UInt8) async -> ChannelDTO? + /// Resolves the contact DTO for a DM event's radio + contact id. Re-fetched + /// at refresh time so the bake reads the post-increment unread count, not the + /// pre-increment DTO captured when the event was dispatched. + var contact: @MainActor (UUID, UUID) async -> ContactDTO? + /// Link-preview cache for the priming view model, so a refresh also warms + /// preview metadata and hero dimensions for the fresh tail; nil skips + /// preview warming. + var linkPreviewCache: @MainActor () -> (any LinkPreviewCaching)? + } + + private let hooks: Hooks + + /// Coalescing window: a sync catch-up delivers a burst of messages for the + /// same conversation; one refresh at the end of the window covers them all. + private let debounce: Duration + + /// One scheduled refresh per conversation; an arrival during the window + /// rides the refresh already scheduled. + private(set) var inFlight: [ChatConversationID: Task] = [:] + + init(hooks: Hooks, debounce: Duration = .milliseconds(250)) { + self.hooks = hooks + self.debounce = debounce + } + + func noteDirectMessage(contact: ContactDTO) { + schedule(.dm(contact: contact)) + } + + func noteChannelMessage(radioID: UUID, channelIndex: UInt8) { + schedule(.channel(radioID: radioID, channelIndex: channelIndex)) + } + + private func schedule(_ kind: ConversationKind) { + let id = kind.coordinatorID + guard inFlight[id] == nil, + !hooks.isConversationActive(kind), + let registry = hooks.registry(), + registry.existingCoordinator(for: id)?.renderState.phase == .loaded + else { return } + + let debounce = debounce + inFlight[id] = Task { [weak self] in + try? await Task.sleep(for: debounce) + guard let self else { return } + defer { self.inFlight[id] = nil } + await self.refresh(kind, id: id) + } + } + + private func refresh(_ kind: ConversationKind, id: ChatConversationID) async { + // Re-validate after the debounce: the user may have opened the chat, the + // registry may have torn down on disconnect, or the LRU may have evicted + // the entry. A cold entry needs no refresh; the next open fetches fresh. + guard !hooks.isConversationActive(kind), + let registry = hooks.registry(), + registry.existingCoordinator(for: id)?.renderState.phase == .loaded + else { return } + + // Re-fetch the DM contact so the divider bakes from the current unread count + // (which also sizes the first page from that count); fall back to the + // event-time DTO if the store lookup misses. + let conversation: ChatConversationType? = switch kind { + case let .dm(contact): + await hooks.contact(contact.radioID, contact.id).map(ChatConversationType.dm) ?? .dm(contact) + case let .channel(radioID, channelIndex): + await hooks.channel(radioID, channelIndex).map(ChatConversationType.channel) + } + guard let conversation, + let dependencies = hooks.dependencies(), + let envInputs = hooks.envInputs(conversation) + else { return } + + // Same throwaway-view-model prime as `AppState.prefetchConversation`: + // only the shared coordinator (held by the registry) outlives it. + // `.prime` binds a revocable writer; if the user opens this chat + // mid-refresh, the live view's `.interactive` bind revokes it and every + // remaining write from this view model no-ops at the coordinator. + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: dependencies, + onNavigateToMap: nil, + linkPreviewCache: hooks.linkPreviewCache(), + chatCoordinatorRegistry: registry, + conversation: conversation, + role: .prime + ) + // A denied writer means an interactive owner appeared between the + // active-conversation check and the bind; nothing to refresh. + guard viewModel.timelineWriter != nil else { return } + viewModel.applyEnvInputs(envInputs) + + switch conversation { + case let .dm(contact): + await viewModel.primeInitialMessages(for: contact) + case let .channel(channel): + // Contacts first so contactNameSet is populated before buildChannelSenders runs. + await viewModel.loadAllContacts(radioID: channel.radioID) + await viewModel.primeInitialChannelMessages(for: channel) + } + // Warm preview metadata and hero dimensions for the fresh tail so the + // next open builds cards synchronously at their final height. + await viewModel.prewarmRecentPreviews() + } +} diff --git a/MC1/State/MessageEventDispatcher.swift b/MC1/State/MessageEventDispatcher.swift index db87c5b7..226a3ad3 100644 --- a/MC1/State/MessageEventDispatcher.swift +++ b/MC1/State/MessageEventDispatcher.swift @@ -53,8 +53,15 @@ final class MessageEventDispatcher { switch event { case let .directMessageReceived(message, contact): stream.send(.directMessageReceived(message: message, contact: contact)) + // Keep a closed conversation's warm coordinator current so a reopen + // renders the new tail on the first frame (no-op when open or cold). + appState?.ensureChatPrewarmRefresher().noteDirectMessage(contact: contact) case let .channelMessageReceived(message, channelIndex): stream.send(.channelMessageReceived(message: message, channelIndex: channelIndex)) + appState?.ensureChatPrewarmRefresher().noteChannelMessage( + radioID: message.radioID, + channelIndex: channelIndex + ) case let .roomMessageReceived(message): stream.send(.roomMessageReceived(message: message, sessionID: message.sessionID)) case let .reactionReceived(messageID, summary): diff --git a/MC1/Views/Chats/ChatConversationInputBar.swift b/MC1/Views/Chats/ChatConversationInputBar.swift index 1885cc41..1645b20e 100644 --- a/MC1/Views/Chats/ChatConversationInputBar.swift +++ b/MC1/Views/Chats/ChatConversationInputBar.swift @@ -9,6 +9,7 @@ struct ChatConversationInputBar: View { let nodeNameByteCount: Int let onSend: (String) async -> Void let onWillSend: () -> Void + let onFocus: () -> Void var body: some View { switch conversationType { @@ -19,7 +20,8 @@ struct ChatConversationInputBar: View { placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, maxBytes: ProtocolLimits.maxDirectMessageLength, isEncrypted: true, - leading: { ChatShareMenu(onInsert: insertShared) } + leading: { ChatShareMenu(onInsert: insertShared) }, + onFocus: onFocus ) { text in onWillSend() Task { await onSend(text) } @@ -37,7 +39,8 @@ struct ChatConversationInputBar: View { : L10n.Chats.Chats.Channel.typePrivate, maxBytes: maxBytes, isEncrypted: channel.isEncryptedChannel, - leading: { ChatShareMenu(onInsert: insertShared) } + leading: { ChatShareMenu(onInsert: insertShared) }, + onFocus: onFocus ) { text in onWillSend() Task { await onSend(text) } diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 32746b40..1bb86163 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -1,11 +1,8 @@ import MC1Services -import OSLog import SwiftUI -private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationMessagesContent") - /// Unified inner content view for both DM and Channel conversations. -/// Handles loading state, empty state, message table, bubble construction, and overlay buttons. +/// Handles loading state, empty state, the messages list, bubble construction, and overlay buttons. struct ChatConversationMessagesContent: View { // MARK: - Identity @@ -18,21 +15,16 @@ struct ChatConversationMessagesContent: View { let envInputs: EnvInputs - // MARK: - Scroll State Bindings + // MARK: - Scroll State @Binding var isAtBottom: Bool @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - - // MARK: - Mention State - - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] + let scrollToBottomRequest: Int + let scrollToTargetRequest: Int let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? + + /// Baked "New Messages" divider id the chat opens scrolled to; nil opens at the bottom. + let openAtDividerItemID: UUID? // MARK: - Sheet State Bindings @@ -41,10 +33,11 @@ struct ChatConversationMessagesContent: View { // MARK: - Callbacks - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void let onRetryMessage: (MessageDTO) -> Void + @Environment(\.appTheme) private var theme + @Environment(\.openURL) private var openURL + // MARK: - Body var body: some View { @@ -54,33 +47,83 @@ struct ChatConversationMessagesContent: View { } else if viewModel.messages.isEmpty { Color.clear } else { - ChatMessagesTableView( - viewModel: viewModel, - contactName: conversationType.navigationTitle, - deviceName: deviceName, - configuration: bubbleConfiguration, - recentEmojisStore: recentEmojisStore, - envInputs: envInputs, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - selectedMessageForActions: $selectedMessageForActions, - imageViewerData: $imageViewerData, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: newMessagesDividerMessageID, - onMentionSeen: onMentionSeen, - onScrollToMention: onScrollToMention, - onRetryMessage: onRetryMessage - ) + messagesList } } } + // MARK: - Messages List + + /// The messages list bound to the current view model. When the conversation has an unread + /// backlog it opens scrolled to the baked "New Messages" divider. + private var messagesList: some View { + ChatTiledView( + items: viewModel.items, + cellContent: cellFactory.makeContent(for:), + contentBackground: theme.surfaces?.canvas, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: scrollToBottomRequest, + scrollToTargetRequest: scrollToTargetRequest, + scrollTargetID: scrollToTargetID, + initialScrollTargetID: openAtDividerItemID, + onLoadOlder: { await viewModel.loadOlderMessages() } + ) + .onChange(of: envInputs) { _, new in + viewModel.applyEnvInputs(new) + } + } + + private var cellFactory: ChatCellContentFactory { + ChatCellContentFactory( + contactName: conversationType.navigationTitle, + deviceName: deviceName, + configuration: bubbleConfiguration, + theme: theme, + openURL: openURL, + resolver: BubbleResolver(viewModel: viewModel), + actions: BubbleActions( + onRetryMessage: onRetryMessage, + onReaction: { emoji, message in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { message in selectedMessageForActions = message }, + onImageTap: { message in + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: viewModel.isGIFImage(for: message.id) + ) + } + }, + onRetryInlineImage: { messageID in + Task { await viewModel.retryImageFetch(for: messageID) } + }, + onRequestPreviewFetch: { messageID in + if viewModel.shouldRequestImageFetch(for: messageID) { + viewModel.requestImageFetch(for: messageID) + } else { + viewModel.requestPreviewFetch(for: messageID) + } + }, + onManualPreviewFetch: { messageID in + if viewModel.shouldRequestImageFetch(for: messageID) { + viewModel.manualFetchImage(for: messageID) + } else { + Task { await viewModel.manualFetchPreview(for: messageID) } + } + }, + onMapPreviewTap: { coordinate in + viewModel.navigateToMap(coordinate) + }, + snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, + requestSnapshot: { MapSnapshotStore.shared.request($0) }, + retrySnapshot: { MapSnapshotStore.shared.retry($0) } + ) + ) + } + // MARK: - Empty State @ViewBuilder @@ -181,18 +224,12 @@ private struct ChannelEmptyMessagesView: View { envInputs: .default, isAtBottom: .constant(true), unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), + scrollToBottomRequest: 0, + scrollToTargetRequest: 0, scrollToTargetID: nil, - newMessagesDividerMessageID: nil, + openAtDividerItemID: nil, selectedMessageForActions: .constant(nil), imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, onRetryMessage: { _ in } ) } @@ -213,18 +250,12 @@ private struct ChannelEmptyMessagesView: View { envInputs: .default, isAtBottom: .constant(true), unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), + scrollToBottomRequest: 0, + scrollToTargetRequest: 0, scrollToTargetID: nil, - newMessagesDividerMessageID: nil, + openAtDividerItemID: nil, selectedMessageForActions: .constant(nil), imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, onRetryMessage: { _ in } ) } diff --git a/MC1/Views/Chats/ChatConversationType.swift b/MC1/Views/Chats/ChatConversationType.swift index 42a50cee..060dc91c 100644 --- a/MC1/Views/Chats/ChatConversationType.swift +++ b/MC1/Views/Chats/ChatConversationType.swift @@ -105,6 +105,18 @@ enum ChatConversationType { } } + /// Key for the shared `ChatCoordinator` in `ChatCoordinatorRegistry`. Coincides + /// with `draftConversationID` today but is named separately so the coordinator + /// and draft namespaces can diverge without silently breaking either. + var coordinatorID: ChatConversationID { + switch self { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(channel): + .channel(radioID: channel.radioID, channelIndex: channel.index) + } + } + var radioID: UUID { switch self { case let .dm(contact): @@ -114,6 +126,17 @@ enum ChatConversationType { } } + /// Unread count carried by the navigated DTO. Gates the open-at-divider target + /// so a fully-read reopen (count 0) never positions to a stale baked divider. + var unreadCount: Int { + switch self { + case let .dm(contact): + contact.unreadCount + case let .channel(channel): + channel.unreadCount + } + } + var isPublicStyleChannel: Bool { switch self { case .dm: diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 6e1944f5..1edd4bc4 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -20,22 +20,18 @@ struct ChatConversationView: View { @State private var conversationType: ChatConversationType let parentViewModel: ChatViewModel? - @State private var chatViewModel = ChatViewModel() + @State private var chatViewModel: ChatViewModel // MARK: - Scroll State @State private var isAtBottom = true @State private var unreadCount = 0 @State private var scrollToBottomRequest = 0 - @State private var scrollToMentionRequest = 0 - @State private var unseenMentionIDs: [UUID] = [] - /// The subset of `unseenMentionIDs` currently off screen, reported up by the chat table. - /// Drives the scroll-to-mention button and is the source for its tap target. - @State private var offscreenMentionIDs: [UUID] = [] + /// Bumped to jump the list to `scrollToTargetID` (deeplink target or the + /// new-messages divider). The library scrolls by item id; no on-screen bubble + /// tracking is involved. + @State private var scrollToTargetRequest = 0 @State private var scrollToTargetID: UUID? - @State private var mentionScrollTask: Task? - @State private var scrollToDividerRequest = 0 - @State private var isDividerVisible = false /// Pending debounced draft persist; cancelled and restarted on each keystroke, /// cancelled-then-flushed synchronously on view teardown and app suspension. @@ -77,7 +73,11 @@ struct ChatConversationView: View { /// Snapshot of env-derived inputs the view model needs to construct /// MessageItems at write time. Recomputed on every render — Equatable - /// drives `.onChange(of: envInputs)` in ChatMessagesTableView. + /// drives `.onChange(of: envInputs)` in ChatConversationMessagesContent. + /// Constructed from the observed `@AppStorage` toggles and `@Environment` + /// values so a settings change while the chat is open re-renders the view and + /// drives `ChatConversationMessagesContent.onChange(of: envInputs)`. The + /// navigation-time prefetch reads the same toggles once via `AppState.chatEnvInputs(...)`. private var currentEnvInputs: EnvInputs { EnvInputs( autoPlayGIFs: autoPlayGIFs, @@ -98,13 +98,49 @@ struct ChatConversationView: View { // MARK: - Init - init(conversationType: ChatConversationType, parentViewModel: ChatViewModel? = nil) { + init( + conversationType: ChatConversationType, + parentViewModel: ChatViewModel? = nil, + coordinatorRegistry: ChatCoordinatorRegistry? = nil + ) { _conversationType = State(initialValue: conversationType) self.parentViewModel = parentViewModel + + // Seed the view model with the shared coordinator up front so a warm + // (prefetched or previously opened) conversation renders its messages on the + // first frame, with no empty flash before the load task binds it. Only the + // reference is attached here; the load task's `configure` installs the + // rebuild hooks on this persistent instance. + let viewModel = ChatViewModel() + if let coordinatorRegistry { + viewModel.attachCoordinator(coordinatorRegistry.coordinator(for: conversationType.coordinatorID)) + } + _chatViewModel = State(initialValue: viewModel) + } + + // MARK: - Open-at-divider + + /// Item the chat opens scrolled to: the baked "New Messages" divider, gated on + /// the navigated DTO still carrying unread. Both conditions must hold, so a + /// fully-read warm coordinator (no baked flag) and a stale-unread DTO from a + /// non-list entry point (no baked flag) each resolve to nil. + private var openAtDividerItemID: UUID? { + guard conversationType.unreadCount > 0 else { return nil } + return chatViewModel.renderState.newMessagesDividerItemID } // MARK: - Body + @ViewBuilder + private var titleAvatar: some View { + switch conversationType { + case let .dm(contact): + ContactAvatar(contact: contact, size: 30) + case let .channel(channel): + ChannelAvatar(channel: channel, size: 30) + } + } + var body: some View { ChatConversationMessagesContent( conversationType: conversationType, @@ -114,18 +150,12 @@ struct ChatConversationView: View { envInputs: currentEnvInputs, isAtBottom: $isAtBottom, unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, + scrollToBottomRequest: scrollToBottomRequest, + scrollToTargetRequest: scrollToTargetRequest, scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: chatViewModel.newMessagesDividerMessageID, + openAtDividerItemID: openAtDividerItemID, selectedMessageForActions: $selectedMessageForActions, imageViewerData: $imageViewerData, - onMentionSeen: { await markMentionSeen(messageID: $0) }, - onScrollToMention: { scrollToNextMention() }, onRetryMessage: { retryMessage($0) } ) .mentionTapHandling( @@ -137,7 +167,7 @@ struct ChatConversationView: View { // input bar inset that follows, placing the strip between content and // the input bar (and lifting it with the keyboard). .chatErrorBanner(chatViewModel: chatViewModel) - .safeAreaInset(edge: .bottom, spacing: 8) { + .safeAreaInset(edge: .bottom, spacing: 0) { ChatConversationInputBar( conversationType: conversationType, composingText: $chatViewModel.composingText, @@ -151,8 +181,10 @@ struct ChatConversationView: View { await chatViewModel.sendChannelMessage(text: text) } }, - onWillSend: { scrollToBottomRequest += 1 } + onWillSend: { scrollToBottomRequest += 1 }, + onFocus: { scrollToBottomRequest += 1 } ) + .chatComposeBarFade(canvas: theme.surfaces?.canvas ?? Color(.systemBackground)) } .overlay(alignment: .bottom) { ChatConversationMentionOverlay( @@ -167,12 +199,17 @@ struct ChatConversationView: View { ), subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel( deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName - ) + ), + glassTitleCapsule: true, + titleIcon: AnyView(titleAvatar), + onTitleTap: { showingInfo = true } ) .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { - showingInfo = true + if #unavailable(iOS 26) { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { + showingInfo = true + } } } } @@ -294,21 +331,19 @@ struct ChatConversationView: View { .chatErrorAlerts(chatViewModel: chatViewModel) // Chrome theming comes from the stack-level themedChrome on the TabView. Re-declaring it // on this pushed destination makes the nav bar appearance re-install after the push, which - // reflows the flipped table's top rows. + // reflows the message list's top rows. + // Always paint an opaque surface — the default theme has no `canvas`, so + // without the `.systemBackground` fallback the empty loading area is + // transparent and the white window shows through on a cold first open + // before messages land. Matches the `chatComposeBarFade` canvas fallback. .background { - if let canvas = theme.surfaces?.canvas { - canvas.ignoresSafeArea() - } + (theme.surfaces?.canvas ?? Color(.systemBackground)).ignoresSafeArea() } } // MARK: - Initial Load (.task) private func performInitialLoad() async { - // Cancel any in-flight mention paging from a previous servicesVersion - mentionScrollTask?.cancel() - mentionScrollTask = nil - // Capture pending scroll target before loading let pendingTarget = appState.navigation.pendingScrollToMessageID if pendingTarget != nil { @@ -316,23 +351,7 @@ struct ChatConversationView: View { } chatViewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { appState.services?.inlineImageDimensionsStore }, - prefetchDataStore: { appState.services?.dataStore } - ), + dependencies: appState.makeChatViewModelDependencies(), onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, linkPreviewCache: linkPreviewCache, chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), @@ -355,12 +374,14 @@ struct ChatConversationView: View { chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) } - await loadUnseenMentions() + // Opening the conversation counts as seeing its mentions. Without on-screen + // bubble tracking, mark them all seen here so chat-list mention badges clear. + await markConversationMentionsSeen() // Trigger scroll to target message if pending (notification deeplink) if let targetID = pendingTarget { scrollToTargetID = targetID - scrollToMentionRequest += 1 + scrollToTargetRequest += 1 } // Clear any notifications for this conversation still sitting in the tray @@ -412,9 +433,6 @@ struct ChatConversationView: View { // MARK: - Cleanup (.onDisappear) private func performCleanup() { - mentionScrollTask?.cancel() - mentionScrollTask = nil - // Clear notification suppression only if this conversation still owns the // active slot; a newer conversation's open may have already claimed it // before this view tears down. @@ -439,14 +457,9 @@ struct ChatConversationView: View { private func handleIncomingMentionIfNeeded(_ messageID: UUID) { // Self-mention gating happens upstream in // `ChatViewModel.recordIncomingMentionIfNeeded`, which only assigns - // `lastIncomingMention` when `containsSelfMention` is true. - Task { - if isAtBottom { - await markNewArrivalMentionSeen(messageID: messageID) - } else { - await loadUnseenMentions() - } - } + // `lastIncomingMention` when `containsSelfMention` is true. The conversation + // is open, so the mention counts as seen regardless of scroll position. + Task { await markNewArrivalMentionSeen(messageID: messageID) } } // MARK: - Conversation Refresh @@ -468,62 +481,36 @@ struct ChatConversationView: View { // MARK: - Mention Tracking - private func loadUnseenMentions() async { - switch conversationType { - case let .dm(contact): - guard let dataStore = appState.services?.dataStore else { return } - do { - unseenMentionIDs = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) - } catch { - logger.error("Failed to load unseen mentions: \(error)") - } + /// Marks every unseen mention in this conversation seen and clears its unread + /// mention count. Called on open: without on-screen bubble tracking, opening + /// the conversation is what marks mentions seen, keeping chat-list badges correct. + private func markConversationMentionsSeen() async { + guard let dataStore = appState.services?.dataStore else { return } + do { + switch conversationType { + case let .dm(contact): + let ids = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) + for id in ids { + try await dataStore.markMentionSeen(messageID: id) + } + try await dataStore.clearUnreadMentionCount(contactID: contact.id) - case let .channel(channel): - guard let services = appState.services else { return } - do { - let allIDs = try await services.dataStore.fetchUnseenChannelMentionIDs( + case let .channel(channel): + let ids = try await dataStore.fetchUnseenChannelMentionIDs( radioID: channel.radioID, channelIndex: channel.index ) - - let blockedNames = await services.syncCoordinator.blockedSenderNames() - if blockedNames.isEmpty { - unseenMentionIDs = allIDs - return - } - - var filteredIDs: [UUID] = [] - for id in allIDs { - do { - if let message = try await services.dataStore.fetchMessage(id: id), - let senderName = message.senderNodeName, - blockedNames.contains(senderName) { - try await services.dataStore.markMentionSeen(messageID: id) - continue - } - } catch { - logger.error("Failed to check/filter mention \(id): \(error)") - } - filteredIDs.append(id) + for id in ids { + try await dataStore.markMentionSeen(messageID: id) } - unseenMentionIDs = filteredIDs - } catch { - logger.error("Failed to load unseen channel mentions: \(error)") + try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) } + parentViewModel?.requestConversationReload() + } catch { + logger.error("Failed to mark conversation mentions seen: \(error)") } } - /// Marks a mention seen and reports whether the result is settled. Returns true when the - /// mention was already seen or the persist succeeded; false only when the persist failed, - /// so the caller can re-attempt rather than treat the id as handled. - @discardableResult - private func markMentionSeen(messageID: UUID) async -> Bool { - guard unseenMentionIDs.contains(messageID) else { return true } - guard await persistMentionSeen(messageID: messageID) else { return false } - unseenMentionIDs.removeAll { $0 == messageID } - return true - } - private func markNewArrivalMentionSeen(messageID: UUID) async { _ = await persistMentionSeen(messageID: messageID) } @@ -547,82 +534,6 @@ struct ChatConversationView: View { } } - // MARK: - Mention Navigation - - private func scrollToNextMention() { - // Target the newest off-screen mention so the first tap lands on the latest unread the - // user hasn't reached; repeated taps walk upward through older mentions, showing the - // earliest last. An on-screen mention is already in view and would make the tap a no-op, - // so the off-screen subset is the right source. - guard let targetID = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: offscreenMentionIDs) else { return } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - return - } - - mentionScrollTask?.cancel() - mentionScrollTask = Task { - do { - let deadline = ContinuousClock.now + .seconds(10) - // Page on the authoritative coordinator-backed messages, not the lagging - // rendered items. loadOlderMessages mutates messages synchronously but only - // schedules the off-main items rebuild, so gating on items overshoots a page - // per spin and can exhaust history before a render lands, tripping the - // destructive not-found branch that hides a real unread mention. - while !chatViewModel.messages.contains(where: { $0.id == targetID }) { - guard chatViewModel.hasMoreMessages else { - logger.warning("Mention \(targetID) not found after exhausting history, removing") - if let dataStore = appState.services?.dataStore { - try? await dataStore.markMentionSeen(messageID: targetID) - } - unseenMentionIDs.removeAll { $0 == targetID } - break - } - // offscreenMentionIDs is an async mirror; a target marked seen mid-paging is - // already gone from unseenMentionIDs, so stop rather than page for a read mention. - guard unseenMentionIDs.contains(targetID) else { break } - guard ContinuousClock.now < deadline else { - logger.warning("Mention \(targetID) paging timed out") - break - } - if chatViewModel.isLoadingOlder { - try await Task.sleep(for: .milliseconds(50)) - continue - } - await chatViewModel.loadOlderMessages() - try Task.checkCancellation() - } - - // Target is in the model; wait (bounded) for the rebuild to surface it in the - // rendered items, which scrollToItem addresses by row, before scrolling. - while chatViewModel.messages.contains(where: { $0.id == targetID }), - !chatViewModel.items.contains(where: { $0.id == targetID }), - ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(50)) - try Task.checkCancellation() - } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - } - } catch is CancellationError { - // Expected when view disappears during paging - } catch { - logger.error("Failed to scroll to mention: \(error)") - } - } - } - - /// Scrolls to a mention and advances the queue. The seen-transition is driven directly - /// rather than waiting for the row's visibility tick, which never fires when the target - /// is already centered (scrollToRow does not move contentOffset), leaving the tap a no-op. - private func issueMentionScroll(to targetID: UUID) { - scrollToTargetID = targetID - scrollToMentionRequest += 1 - Task { await markMentionSeen(messageID: targetID) } - } - // MARK: - Mention Suggestions private var activeMentionQuery: String? { diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index 79b8c754..ce06245e 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -5,50 +5,95 @@ import SwiftUI /// app substituted a corrected value, signalling that the displayed time was adjusted. private let correctedClockBadgeSymbol = "clock.badge.exclamationmark" -/// Renders the send-time / hop / path / region footer from a `MessageFooter`. -/// HStack at standard dynamic type sizes; VStack when -/// `dynamicTypeSize.isAccessibilitySize` is true. Each sub-row carries its own -/// `.accessibilityLabel(...)`; the container uses -/// `.accessibilityElement(children: .combine)` so VoiceOver surfaces the row as -/// a single rotor stop. +/// Renders the in-bubble footer from a `MessageFooter`: send time, then the +/// incoming network slots (hop / path / region) or the outgoing status slots +/// (repeat count + delivery-status icon). Segments are joined with " • " +/// separators at standard dynamic type sizes and stacked vertically at +/// accessibility sizes. Each segment carries its own `.accessibilityLabel(...)`; +/// the container uses `.accessibilityElement(children: .combine)` so VoiceOver +/// surfaces the row as a single rotor stop. struct BubbleFooterRow: View { let footer: MessageFooter let dynamicTypeSize: DynamicTypeSize + /// Color for the send-time text. Varies by bubble: `.secondary` on the gray + /// incoming bubble, a translucent outgoing-text color on the accent-colored + /// outgoing bubble where `.secondary` would wash out. Hop/path/region rows + /// stay `.secondary` because they only ever appear on incoming bubbles. + var timeColor: Color = .secondary + /// Retry callback for the failed-status icon; only outgoing bubbles pass one. + var onRetry: (() -> Void)? var body: some View { - if dynamicTypeSize.isAccessibilitySize { - VStack(alignment: .leading, spacing: 2) { - footerContents(allowsWrap: true) + let segments = footerSegments + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 2) { + ForEach(segments.indices, id: \.self) { segments[$0] } + } + } else { + HStack(spacing: 4) { + ForEach(segments.indices, id: \.self) { index in + segments[index] + } + } } - .accessibilityElement(children: .combine) - } else { - HStack(spacing: 4) { - footerContents(allowsWrap: false) - } - .accessibilityElement(children: .combine) } + .accessibilityElement(children: .combine) } - @ViewBuilder - private func footerContents(allowsWrap: Bool) -> some View { + /// Canonical segment order is status → time → badges. Incoming bubbles hug the + /// leading edge, so they render that order left-to-right. Outgoing bubbles hug + /// the trailing edge, so the order is reversed to read right-to-left and stay + /// anchored against the bubble's inner edge. Accessibility sizes stack the + /// canonical order vertically regardless. + private var footerSegments: [AnyView] { + var status: [AnyView] = [] + var time: [AnyView] = [] + var badges: [AnyView] = [] + + if footer.showStatusRow { + status.append(AnyView(BubbleStatusFooter(footer: footer, color: timeColor, onRetry: onRetry))) + } + if let sendTime = footer.sendTimeToShow { - BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) + time.append(AnyView(BubbleSendTimeFooter( + date: sendTime, + wasCorrected: footer.sendTimeWasCorrected, + color: timeColor + ))) + } + + if footer.heardRepeats > 0, footer.showStatusRow { + badges.append(AnyView(BubbleRepeatFooter(count: footer.heardRepeats, color: timeColor))) + } + if footer.sendCount > 1, footer.showStatusRow { + badges.append(AnyView(BubbleSendCountFooter(count: footer.sendCount, color: timeColor))) } if footer.showHop { - BubbleHopCountFooter(hopCount: footer.hopCount) + badges.append(AnyView(BubbleHopCountFooter(hopCount: footer.hopCount))) } if let formattedPath = footer.formattedPath { - BubblePathFooter(formattedPath: formattedPath) + badges.append(AnyView(BubblePathFooter(formattedPath: formattedPath))) } if let region = footer.regionToShow { - BubbleRegionFooter(regionName: region, allowsWrap: allowsWrap) + badges.append(AnyView(BubbleRegionFooter( + regionName: region, + allowsWrap: dynamicTypeSize.isAccessibilitySize + ))) } + + let segments = status + time + badges + // reverse for outgoing messages for proper RTL alignment + return footer.showStatusRow && !dynamicTypeSize.isAccessibilitySize + ? segments.reversed() + : segments } } private struct BubbleSendTimeFooter: View { let date: Date let wasCorrected: Bool + let color: Color private var timeText: String { date.formatted(date: .omitted, time: .shortened) @@ -61,44 +106,60 @@ private struct BubbleSendTimeFooter: View { } var body: some View { - HStack(spacing: 4) { + HStack(spacing: 2) { if wasCorrected { Image(systemName: correctedClockBadgeSymbol) } Text(timeText) } .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(color) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel) } } +/// Rounded capsule chip shared by the hop / path / region / repeat footer +/// slots: compact interior padding with a hairline capsule border. +private extension View { + func footerChip(color: Color) -> some View { + padding(.horizontal, 6) + .padding(.vertical, 2) + .overlay(Capsule().strokeBorder(color.opacity(0.35), lineWidth: 1)) + } +} + private struct BubbleHopCountFooter: View { let hopCount: Int var body: some View { - HStack(spacing: 4) { + HStack(spacing: 2) { Image(systemName: "arrowshape.bounce.right") Text("\(hopCount)") } .font(.caption2) .foregroundStyle(.secondary) + .footerChip(color: .secondary) .accessibilityElement(children: .combine) .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) } } +/// Path chip that shows the hop IDs on a single line. `MessagePathFormatter` already middle-truncated +/// the string to four nodes, so this renders it verbatim — no width-measuring `ViewThatFits`, which +/// had hitched the per-cell layout during scroll. The accessibility label reads the same string, so +/// VoiceOver matches what's on screen. private struct BubblePathFooter: View { let formattedPath: String var body: some View { - HStack(spacing: 4) { + HStack(spacing: 2) { Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - Text(formattedPath) + Text(formattedPath).lineLimit(1) } .font(.caption2.monospaced()) .foregroundStyle(.secondary) + .footerChip(color: .secondary) .accessibilityElement(children: .combine) .accessibilityLabel(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath)) } @@ -109,14 +170,172 @@ private struct BubbleRegionFooter: View { let allowsWrap: Bool var body: some View { - HStack(spacing: 4) { + HStack(spacing: 2) { Image(systemName: "globe") Text(regionName) .lineLimit(allowsWrap ? nil : 1) } .font(.caption2) .foregroundStyle(.secondary) + .footerChip(color: .secondary) .accessibilityElement(children: .combine) .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) } } + +/// Repeat count heard back over the mesh: the repeat glyph and the count. +private struct BubbleRepeatFooter: View { + let count: Int + let color: Color + + private var accessibilityLabel: String { + let word = count == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + return "\(count) \(word)" + } + + var body: some View { + HStack(spacing: 2) { + Image(systemName: "repeat") + Text("\(count)") + } + .font(.caption2) + .foregroundStyle(color) + .footerChip(color: color) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } +} + +/// Number of times an outgoing message was (re)sent over the mesh, shown as a +/// `2x`-style multiplier badge. Only appears once the send count exceeds one. +private struct BubbleSendCountFooter: View { + let count: Int + let color: Color + + var body: some View { + HStack(spacing: 2) { + Image(systemName: "paperplane") + Text("\(count)x") + } + .font(.caption2) + .foregroundStyle(color) + .footerChip(color: color) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.Status.sentMultiple(count)) + } +} + +/// Outgoing delivery-status icon shown trailing inside the bubble. A spinner +/// while in flight (with an `n/N` attempt count while retrying), a dotted check +/// for a queued channel broadcast, a filled check once delivered, and a tappable +/// retry glyph on failure. +private struct BubbleStatusFooter: View { + let footer: MessageFooter + let color: Color + let onRetry: (() -> Void)? + + @State private var retryInvocationCounter: Int = 0 + + var body: some View { + Group { + switch footer.status { + case .pending, .sending: + spinner + case .retrying: + HStack(spacing: 3) { + spinner + if footer.maxRetryAttempts > 0 { + Text("\(footer.retryAttempt + 1)/\(footer.maxRetryAttempts)") + .font(.caption2) + } + } + case .sent: + // A DM `.sent` only means the radio queued the packet; a missing ACK can + // still flip it to `.failed`, so it reads as in-flight. A channel `.sent` + // has no ACK and is terminal success. + if footer.isChannelMessage { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + } else { + spinner + } + case .delivered: + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + case .failed: + Button { + retryInvocationCounter &+= 1 + onRetry?() + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .contentShape(.rect) + } + .buttonStyle(.plain) + .sensoryFeedback(.selection, trigger: retryInvocationCounter) + } + } + .foregroundStyle(color) + .tint(color) + .accessibilityLabel(MessageStatusText.text(for: footer)) + } + + private var spinner: some View { + ProgressView() + .controlSize(.mini) + } +} + +/// Localized status text for the outgoing delivery state. The visual footer now +/// renders this state as an icon, but the string is still surfaced by +/// `UnifiedMessageBubble.accessibilityMessageLabel` and the status icon's own +/// accessibility label so VoiceOver conveys the same meaning. +enum MessageStatusText { + static func text(for footer: MessageFooter) -> String { + switch footer.status { + case .pending, .sending: + return L10n.Chats.Chats.Message.Status.sending + case .sent: + guard footer.isChannelMessage else { + return L10n.Chats.Chats.Message.Status.sending + } + var parts: [String] = [] + if footer.heardRepeats > 0 { + let repeatWord = footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(footer.heardRepeats) \(repeatWord)") + } + if footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(footer.sendCount)) + } else { + parts.append(L10n.Chats.Chats.Message.Status.sent) + } + return parts.joined(separator: " • ") + case .delivered: + var parts: [String] = [] + if footer.heardRepeats > 0 { + let repeatWord = footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(footer.heardRepeats) \(repeatWord)") + } + parts.append(L10n.Chats.Chats.Message.Status.delivered) + if footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(footer.sendCount)) + } + return parts.joined(separator: " • ") + case .failed: + return L10n.Chats.Chats.Message.Status.failed + case .retrying: + let displayAttempt = footer.retryAttempt + 1 + let maxAttempts = footer.maxRetryAttempts + if maxAttempts > 0 { + return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) + } + return L10n.Chats.Chats.Message.Status.retrying + } + } +} diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 9722bef1..8845f21f 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -21,13 +21,17 @@ struct BubbleFragmentStack: View, Equatable { /// function of `item.content`, so equal items yield equal box fragments. let layout: FragmentLayout let bubbleColor: Color + /// Color for the in-bubble send time. Passed in (not read from the theme + /// environment) for the same reason as `bubbleColor`: keep body invalidation + /// off the per-cell env-read path. + let timeColor: Color let callbacks: MessageBubbleCallbacks let imageResolver: (ImageReference) -> UIImage? @Environment(\.dynamicTypeSize) private var dynamicTypeSize nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { - lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor + lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor && lhs.timeColor == rhs.timeColor } private var hasFooter: Bool { @@ -35,6 +39,7 @@ struct BubbleFragmentStack: View, Equatable { || item.footer.showHop || item.footer.formattedPath != nil || item.footer.regionToShow != nil + || item.footer.showStatusRow } /// The source URL when the inline image is parked at the scope-off @@ -47,10 +52,21 @@ struct BubbleFragmentStack: View, Equatable { } var body: some View { - let stack = VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 4) { + let stack = VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { + // Stack alignment carries the footer placement: the time sits at the + // bubble's trailing edge for outgoing, leading for incoming. Driving it + // through alignment (rather than a greedy `Spacer`/`maxWidth`) keeps the + // bubble hugging its content — a flexible-width child here leaves the + // self-sizing hosting cell without a resolvable intrinsic width, which + // SwiftUI surfaces as a fatal "invalid reuse after initialization failure". + VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 2) { if let textPayload = layout.textPayload { - MessageTextView(text: textPayload) + // Native `Text`: the precomputed `formatted` string already carries every run's color, + // underline, bold, and `.link`, so link taps route through the injected `\.openURL` and + // Dynamic Type scales for free. `foregroundStyle` colors the raw fallback when unformatted. + Text(textPayload.formatted ?? AttributedString(textPayload.raw)) + .font(.body) + .foregroundStyle(textPayload.baseColor.swiftUIColor) } if let disabledImageURL { @@ -61,8 +77,13 @@ struct BubbleFragmentStack: View, Equatable { ) } - if !item.envelope.isOutgoing, hasFooter { - BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) + if hasFooter { + BubbleFooterRow( + footer: item.footer, + dynamicTypeSize: dynamicTypeSize, + timeColor: timeColor, + onRetry: callbacks.onRetry + ) } } .bubbleContentPadding() @@ -93,3 +114,16 @@ struct BubbleFragmentStack: View, Equatable { } } } + +private extension BaseColorSlot { + /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at render time. Outgoing + /// bubbles render on a filled background and need white text; incoming bubbles use the system + /// primary colour. Lives in the MC1 layer because MC1Services intentionally has no SwiftUI + /// dependency. + var swiftUIColor: Color { + switch self { + case .outgoing: .white + case .incoming: .primary + } + } +} diff --git a/MC1/Views/Chats/Components/BubbleStatusRow.swift b/MC1/Views/Chats/Components/BubbleStatusRow.swift deleted file mode 100644 index 52362f50..00000000 --- a/MC1/Views/Chats/Components/BubbleStatusRow.swift +++ /dev/null @@ -1,96 +0,0 @@ -import MC1Services -import SwiftUI - -/// Outgoing-message status row: retry button (when failed), failure glyph, and -/// status text. The static `statusText(for:)` helper is also consumed by -/// `UnifiedMessageBubble.accessibilityMessageLabel` so VoiceOver surfaces the -/// same text as the visual row. -struct BubbleStatusRow: View { - let item: MessageItem - let onRetry: (() -> Void)? - - @State private var retryInvocationCounter: Int = 0 - - var body: some View { - HStack(spacing: 4) { - if item.footer.status == .failed, let onRetry { - Button { - retryInvocationCounter &+= 1 - onRetry() - } label: { - HStack(spacing: 2) { - Image(systemName: "arrow.clockwise") - Text(L10n.Chats.Chats.Message.Status.retry) - } - .font(.caption2) - .contentShape(.rect) - } - .buttonStyle(.plain) - .foregroundStyle(.blue) - .sensoryFeedback(.selection, trigger: retryInvocationCounter) - } - - if item.footer.status == .failed { - Image(systemName: "exclamationmark.circle") - .font(.caption2) - .foregroundStyle(.red) - } - - Text(Self.statusText(for: item)) - .font(.caption2) - .foregroundStyle(.secondary) - .contentTransition(.opacity) - } - .padding(.trailing, 4) - } - - static func statusText(for item: MessageItem) -> String { - switch item.footer.status { - case .pending, .sending: - return L10n.Chats.Chats.Message.Status.sending - case .sent: - // A DM `.sent` means only that the radio queued the packet, not that it - // was delivered; a missing end-to-end ACK later flips the row to `.failed`. - // Render it as in-progress so the user never sees a settled "Sent" that - // becomes "Failed". A channel `.sent` has no ACK and is terminal success. - guard item.footer.isChannelMessage else { - return L10n.Chats.Chats.Message.Status.sending - } - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } else { - parts.append(L10n.Chats.Chats.Message.Status.sent) - } - return parts.joined(separator: " • ") - case .delivered: - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - parts.append(L10n.Chats.Chats.Message.Status.delivered) - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } - return parts.joined(separator: " • ") - case .failed: - return L10n.Chats.Chats.Message.Status.failed - case .retrying: - let displayAttempt = item.footer.retryAttempt + 1 - let maxAttempts = item.footer.maxRetryAttempts - if maxAttempts > 0 { - return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) - } - return L10n.Chats.Chats.Message.Status.retrying - } - } -} diff --git a/MC1/Views/Chats/Components/ChatCellContentFactory.swift b/MC1/Views/Chats/Components/ChatCellContentFactory.swift index c08f1397..f4cc119b 100644 --- a/MC1/Views/Chats/Components/ChatCellContentFactory.swift +++ b/MC1/Views/Chats/Components/ChatCellContentFactory.swift @@ -1,11 +1,10 @@ import MC1Services import SwiftUI -/// Builds the SwiftUI body for a chat cell from a `MessageItem`. Owned by -/// `ChatMessagesTableView` for the lifetime of the bound `ChatViewModel` -/// and passed to `ChatTableView` so the closure handed to -/// `UIHostingConfiguration` captures a stable struct rather than rebuilding -/// its closure body each SwiftUI render. +/// Builds the SwiftUI body for a chat cell from a `MessageItem`. Built by +/// `ChatConversationMessagesContent` for the bound `ChatViewModel` and routed +/// to `ChatTiledView` (via `CellContentHost`) so the closure that `MessagingUI` +/// hosts in each cell reflects the current theme and callbacks. /// /// Pinned to `@MainActor` because `BubbleResolver` and `BubbleActions` /// proxy to `ChatViewModel` (an `@Observable @MainActor` class). Not @@ -16,18 +15,16 @@ struct ChatCellContentFactory { let deviceName: String let configuration: MessageBubbleConfiguration /// The active theme, injected into each hosted cell. Custom environment values do not - /// auto-cross the `UIHostingConfiguration` boundary, so the bubble fill would otherwise - /// see only `Theme.default`. The factory carries the current theme on every render, but a - /// visible cell adopts a theme change only when it is reloaded — driven by - /// `reconfigureAllItems()`, which the table fires when its tracked theme id changes — or - /// recycled; re-wiring the cell closure alone does not re-host live cells. + /// auto-cross the cell-hosting boundary, so the bubble fill would otherwise see only + /// `Theme.default`. A visible cell adopts a theme change only when reconfigured or + /// recycled; `ChatTiledView` rebuilds the whole list (keyed on the appearance identity) + /// when the theme changes so every cell repaints. let theme: Theme /// The chat-content link router, injected into each hosted cell. Like `\.appTheme`, the - /// `\.openURL` action does not cross the `UIHostingConfiguration` boundary, so a message - /// link (coordinate, mention, hashtag, contact, channel) would otherwise reach the default + /// `\.openURL` action does not cross the cell-hosting boundary, so a message link + /// (coordinate, mention, hashtag, contact, channel) would otherwise reach the default /// system handler and never route through `ChatLinkRouter`. The factory carries the action - /// the surrounding `mentionTapHandling` installed; a live cell adopts a change only on - /// reconfigure or recycle, matching the theme injection. + /// the surrounding `mentionTapHandling` installed. let openURL: OpenURLAction let resolver: BubbleResolver let actions: BubbleActions diff --git a/MC1/Views/Chats/Components/ChatComposeBarFade.swift b/MC1/Views/Chats/Components/ChatComposeBarFade.swift new file mode 100644 index 00000000..62dc134a --- /dev/null +++ b/MC1/Views/Chats/Components/ChatComposeBarFade.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// Fade behind the floating compose bar: transparent at the bar's top edge so messages stay +/// legible until they scroll under the bar, opaque toward the bottom. Applied as the input +/// bar's background so it lifts with the bar and the keyboard, and extends over the home +/// indicator (when the keyboard is down) via the bottom container safe area. +/// +/// The chat list is already edge-to-edge (the `TiledView` collection view ignores the safe +/// area and reserves it as content inset), so no separate edge-to-edge modifier is needed and +/// there is intentionally no top fade. +@available(iOS 26.0, *) +private struct ChatComposeBarFade: View { + let canvas: Color + + var body: some View { + LinearGradient( + stops: [ + .init(color: canvas.opacity(0), location: 0), + .init(color: canvas.opacity(0.2), location: 0.4), + .init(color: canvas.opacity(0.9), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(.container, edges: .bottom) + .allowsHitTesting(false) + } +} + +extension View { + @ViewBuilder + func chatComposeBarFade(canvas: Color) -> some View { + if #available(iOS 26.0, *) { + background { ChatComposeBarFade(canvas: canvas) } + } else { + self + } + } +} diff --git a/MC1/Views/Chats/Components/ChatComposerTextView.swift b/MC1/Views/Chats/Components/ChatComposerTextView.swift index 69c168f8..95804003 100644 --- a/MC1/Views/Chats/Components/ChatComposerTextView.swift +++ b/MC1/Views/Chats/Components/ChatComposerTextView.swift @@ -32,6 +32,8 @@ struct ChatComposerTextView: UIViewRepresentable { /// consumed and focus retained), `false` when gated off (Return inserts a /// newline instead). let onSend: () -> Bool + /// Called when the field becomes first responder. + let onFocus: () -> Void func makeUIView(context: Context) -> ChatComposerUITextView { let textView = ChatComposerUITextView(usingTextLayoutManager: false) @@ -117,6 +119,10 @@ struct ChatComposerTextView: UIViewRepresentable { lastFocusRequest = parent.focusRequest } + func textViewDidBeginEditing(_: UITextView) { + parent.onFocus() + } + func textViewDidChange(_ textView: UITextView) { // Skip the redundant write when the text already matches the binding. // The post-send clear edits the field from inside `updateUIView`, which diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index e1a15bad..808ffd20 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -2,6 +2,11 @@ import MC1Services import SwiftUI import UIKit +enum ChatInputMetrics { + /// Shared height for the compose field and its flanking glass buttons so the three controls align. + static let controlHeight: CGFloat = 36 +} + /// Reusable chat input bar with configurable styling struct ChatInputBar: View { @Environment(\.appState) private var appState @@ -13,11 +18,13 @@ struct ChatInputBar: View { let maxBytes: Int let isEncrypted: Bool @ViewBuilder let leading: () -> Leading + var onFocus: () -> Void = {} let onSend: (String) -> Void @State private var isCoolingDown = false @State private var sendInvocationCounter: Int = 0 @State private var composerProxy = ChatComposerProxy() + @Namespace private var glassNamespace private var byteCount: Int { text.utf8.count @@ -35,29 +42,40 @@ struct ChatInputBar: View { var body: some View { HStack(alignment: .bottom, spacing: 12) { leading() - ChatInputTextField( - text: $text, - placeholder: placeholder, - focusRequest: focusRequest, - isEncrypted: isEncrypted, - proxy: composerProxy, - onSend: handleHardwareSend - ) - ChatSendButtonWithCounter( - canSend: canSend, - isOverLimit: isOverLimit, - shouldShowCharacterCount: shouldShowCharacterCount, - byteCount: byteCount, - maxBytes: maxBytes, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: send - ) + LiquidGlassContainer(spacing: 12) { + HStack(alignment: .bottom, spacing: 12) { + ChatInputTextField( + text: $text, + placeholder: placeholder, + focusRequest: focusRequest, + isEncrypted: isEncrypted, + proxy: composerProxy, + onSend: handleHardwareSend, + onFocus: onFocus, + glassNamespace: glassNamespace + ) + if canSend || shouldShowCharacterCount { + ChatSendButtonWithCounter( + canSend: canSend, + isOverLimit: isOverLimit, + shouldShowCharacterCount: shouldShowCharacterCount, + byteCount: byteCount, + maxBytes: maxBytes, + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: send, + glassNamespace: glassNamespace + ) + } + } + } } .padding(.horizontal) .padding(.vertical, 8) .inputBarBackground(themedCanvas: theme.surfaces?.canvas) .sensoryFeedback(.start, trigger: sendInvocationCounter) + .animation(.smooth(duration: 0.28), value: canSend) + .animation(.smooth(duration: 0.28), value: shouldShowCharacterCount) } private var sendAccessibilityLabel: String { @@ -144,6 +162,8 @@ private struct ChatInputTextField: View { let isEncrypted: Bool let proxy: ChatComposerProxy let onSend: () -> Bool + let onFocus: () -> Void + let glassNamespace: Namespace.ID var body: some View { ChatComposerTextView( @@ -151,9 +171,10 @@ private struct ChatInputTextField: View { focusRequest: focusRequest, isEncrypted: isEncrypted, proxy: proxy, - onSend: onSend + onSend: onSend, + onFocus: onFocus ) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: ChatInputMetrics.controlHeight) .overlay(alignment: .topLeading) { if text.isEmpty { Text(placeholder) @@ -174,6 +195,7 @@ private struct ChatInputTextField: View { .accessibilityHidden(true) } .textFieldBackground() + .liquidGlassID("chatComposer", in: glassNamespace) } } @@ -186,15 +208,19 @@ private struct ChatSendButtonWithCounter: View { let sendAccessibilityLabel: String let sendAccessibilityHint: String let onSend: () -> Void + let glassNamespace: Namespace.ID var body: some View { VStack(spacing: 4) { - ChatSendButton( - canSend: canSend, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: onSend - ) + if canSend { + ChatSendButton( + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: onSend + ) + .liquidGlassID("chatSend", in: glassNamespace) + .transition(chatSendButtonTransition) + } if shouldShowCharacterCount { ChatCharacterCountLabel( byteCount: byteCount, @@ -206,6 +232,14 @@ private struct ChatSendButtonWithCounter: View { } } +private var chatSendButtonTransition: AnyTransition { + if #available(iOS 26.0, *) { + .opacity + } else { + .scale(scale: 0.5, anchor: .trailing).combined(with: .opacity) + } +} + private struct ChatCharacterCountLabel: View { let byteCount: Int let maxBytes: Int @@ -221,25 +255,20 @@ private struct ChatCharacterCountLabel: View { } private struct ChatSendButton: View { - let canSend: Bool let sendAccessibilityLabel: String let sendAccessibilityHint: String let onSend: () -> Void - @Environment(\.appTheme) private var theme - - private var sendButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } - } - var body: some View { Button(action: onSend) { - Image(systemName: "arrow.up.circle.fill") - .font(sendButtonFont) - .foregroundStyle(canSend ? theme.accentColor : .secondary) + Image(systemName: "arrow.up") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: ChatInputMetrics.controlHeight, height: ChatInputMetrics.controlHeight) + .sendButtonBackground() + .contentShape(Circle()) } - .sendButtonStyle() - .disabled(!canSend) + .buttonStyle(.plain) .accessibilityLabel(sendAccessibilityLabel) .accessibilityHint(sendAccessibilityHint) } @@ -249,11 +278,11 @@ private struct ChatSendButton: View { private extension View { @ViewBuilder - func sendButtonStyle() -> some View { + func sendButtonBackground() -> some View { if #available(iOS 26.0, *) { - buttonStyle(.glass) + glassEffect(.regular.tint(.blue), in: .circle) } else { - padding(.vertical, 4) + background(Color.blue, in: Circle()) } } diff --git a/MC1/Views/Chats/Components/ChatMessagesTableView.swift b/MC1/Views/Chats/Components/ChatMessagesTableView.swift deleted file mode 100644 index 6841654d..00000000 --- a/MC1/Views/Chats/Components/ChatMessagesTableView.swift +++ /dev/null @@ -1,169 +0,0 @@ -import MC1Services -import SwiftUI - -/// Messages table with ChatTableView, overlay scroll buttons, and divider state management -struct ChatMessagesTableView: View { - @Bindable var viewModel: ChatViewModel - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let recentEmojisStore: RecentEmojisStore - let envInputs: EnvInputs - - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - @Binding var selectedMessageForActions: MessageDTO? - @Binding var imageViewerData: ImageViewerData? - - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] - let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void - let onRetryMessage: (MessageDTO) -> Void - - @State private var hasDismissedDividerButton = false - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.openURL) private var openURL - - private var showDividerButton: Bool { - newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerButton - } - - var body: some View { - let mentionIDSet = Set(unseenMentionIDs) - let factory = ChatCellContentFactory( - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - theme: theme, - openURL: openURL, - resolver: BubbleResolver(viewModel: viewModel), - actions: BubbleActions( - onRetryMessage: onRetryMessage, - onReaction: { emoji, message in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - }, - onLongPress: { message in selectedMessageForActions = message }, - onImageTap: { message in - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: viewModel.isGIFImage(for: message.id) - ) - } - }, - onRetryInlineImage: { messageID in - Task { await viewModel.retryImageFetch(for: messageID) } - }, - onRequestPreviewFetch: { messageID in - if viewModel.shouldRequestImageFetch(for: messageID) { - viewModel.requestImageFetch(for: messageID) - } else { - viewModel.requestPreviewFetch(for: messageID) - } - }, - onManualPreviewFetch: { messageID in - if viewModel.shouldRequestImageFetch(for: messageID) { - viewModel.manualFetchImage(for: messageID) - } else { - Task { await viewModel.manualFetchPreview(for: messageID) } - } - }, - onMapPreviewTap: { coordinate in - viewModel.navigateToMap(coordinate) - }, - snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, - requestSnapshot: { MapSnapshotStore.shared.request($0) }, - retrySnapshot: { MapSnapshotStore.shared.retry($0) } - ) - ) - ChatTableView( - items: viewModel.items, - cellContent: factory.makeContent(for:), - contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { item in - item.envelope.containsSelfMention - && !item.envelope.mentionSeen - && mentionIDSet.contains(item.id) - }, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - onMentionBecameVisible: { id in - await onMentionSeen(id) - }, - onSecondaryClick: { item in - if let message = viewModel.message(for: item) { - selectedMessageForActions = message - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { release in - Task { @MainActor in - await viewModel.loadOlderMessages() - release() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerButton { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerButton = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !offscreenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: offscreenMentionIDs.count, - onTap: { onScrollToMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerButton) - .animation(.snappy(duration: 0.2), value: offscreenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - .onChange(of: newMessagesDividerMessageID) { _, _ in - hasDismissedDividerButton = false - } - .onChange(of: envInputs) { _, new in - viewModel.applyEnvInputs(new) - } - } -} diff --git a/MC1/Views/Chats/Components/ChatScrollConstants.swift b/MC1/Views/Chats/Components/ChatScrollConstants.swift index 817d6573..7f123006 100644 --- a/MC1/Views/Chats/Components/ChatScrollConstants.swift +++ b/MC1/Views/Chats/Components/ChatScrollConstants.swift @@ -1,38 +1,9 @@ -import Foundation +import CoreGraphics -/// Tuning constants for ChatTableViewController scroll scheduling. A file-scope -/// enum (not nested in the generic controller) so static stored properties are -/// legal. +/// Tuning constants for the chat scroll surface. enum ChatScrollConstants { - /// Maximum times a scroll-to-target is retried while the applied snapshot - /// catches up to the items model before the target is abandoned. - static let pendingScrollMaxRetries = 3 - - /// Delay between scroll-to-target retries while waiting for the applied - /// snapshot to drain. - static let pendingScrollRetryDelay: Duration = .milliseconds(80) - - /// contentOffset.y at or below which the flipped table is treated as resting - /// at the visual bottom. Small to absorb float imprecision only. - static let bottomDetectionEpsilon: CGFloat = 1 - - /// Looser bottom threshold used right after a programmatic scroll-to-bottom, - /// whose animation may not land exactly at zero. - static let bottomLandingEpsilon: CGFloat = 10 - - /// Distance (in rows) from the oldest loaded message at which to trigger - /// loading the next older page. - static let nearTopTriggerDistance = 10 - - /// Delay that lets a SwiftUI/table layout pass settle before a follow-up - /// scroll or visibility check reads positions. - static let layoutSettleDelay: Duration = .milliseconds(100) - - /// Delay before replaying a pending scroll-to-target queued during an items - /// apply, giving the snapshot time to install the target row. - static let pendingScrollInitialDelay: Duration = .milliseconds(120) - - /// Delay before an animated scroll-to-target begins, letting the target row's - /// self-sizing layout settle so the landing offset is correct. - static let scrollToTargetDelay: Duration = .milliseconds(180) + /// Distance from the bottom (in points) at or below which the list is treated + /// as resting at the visual bottom, hiding the scroll-to-bottom button and + /// re-enabling auto-scroll on append. + static let bottomDetectionThreshold: CGFloat = 40 } diff --git a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift b/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift deleted file mode 100644 index dfc72a22..00000000 --- a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift +++ /dev/null @@ -1,15 +0,0 @@ -import UIKit - -/// `NSObject` proxy used as the `CADisplayLink` target so the link can -/// retain the proxy without retaining the controller. The proxy holds a -/// closure rather than a typed `weak` reference because -/// `ChatTableViewController` is generic and exposes no Objective-C -/// representable class type the proxy could store directly. -@MainActor -final class ChatScrollDisplayLinkProxy: NSObject { - var onTick: (() -> Void)? - - @objc func tick(_ link: CADisplayLink) { - onTick?() - } -} diff --git a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift b/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift deleted file mode 100644 index b2b68f21..00000000 --- a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -enum ChatScrollToMentionPolicy { - static func shouldScrollToBottom(mentionTargetID: UUID?, newestItemID: UUID?) -> Bool { - guard let mentionTargetID, let newestItemID else { return false } - return mentionTargetID == newestItemID - } - - /// Picks which off-screen mention a button tap scrolls to. `offscreenMentions` is ordered - /// oldest-to-newest, so the newest (last) is the latest unread the user hasn't reached; - /// repeated taps consume it and walk upward through older mentions, showing the earliest last. - static func nextTarget(offscreenMentions: [UUID]) -> UUID? { - offscreenMentions.last - } -} diff --git a/MC1/Views/Chats/Components/ChatShareMenu.swift b/MC1/Views/Chats/Components/ChatShareMenu.swift index f4b759bd..8c56ff47 100644 --- a/MC1/Views/Chats/Components/ChatShareMenu.swift +++ b/MC1/Views/Chats/Components/ChatShareMenu.swift @@ -71,9 +71,8 @@ struct ChatShareMenu: View { return Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) } - /// Matches the send button's font so the two input-bar buttons are the same height. - private var plusButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } + private var plusIconColor: Color { + if #available(iOS 26.0, *) { .primary } else { Color(.systemGray) } } var body: some View { @@ -98,10 +97,13 @@ struct ChatShareMenu: View { } .disabled(!canShareMyInfo) } label: { - Image(systemName: "plus.circle.fill") - .font(plusButtonFont) + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(plusIconColor) + .frame(width: ChatInputMetrics.controlHeight, height: ChatInputMetrics.controlHeight) + .plusButtonBackground() } - .shareButtonStyle() + .buttonStyle(.plain) .accessibilityLabel(L10n.Chats.Chats.Input.ShareButton.accessibilityLabel) .sheet(isPresented: $isShowingContactPicker) { ShareContactPickerSheet(onInsert: onInsert) @@ -142,14 +144,12 @@ struct ChatShareMenu: View { // MARK: - Platform-Conditional Styling private extension View { - /// Mirrors the chat send button's treatment for visual parity: a glass button - /// on iOS 26+, and the same plain vertical padding fallback on earlier versions. @ViewBuilder - func shareButtonStyle() -> some View { + func plusButtonBackground() -> some View { if #available(iOS 26.0, *) { - buttonStyle(.glass) + glassEffect(.regular.interactive(), in: .circle) } else { - padding(.vertical, 4) + background(Color(.systemGray5), in: Circle()) } } } diff --git a/MC1/Views/Chats/Components/ChatTableView.swift b/MC1/Views/Chats/Components/ChatTableView.swift deleted file mode 100644 index 3e123fa9..00000000 --- a/MC1/Views/Chats/Components/ChatTableView.swift +++ /dev/null @@ -1,1310 +0,0 @@ -import os -import SwiftUI -import UIKit - -/// Logs the bottom safe-area inset across keyboard transitions so a residual -/// inset that fails to collapse after dismissal can be diagnosed. -private let chatKeyboardLogger = Logger(subsystem: "com.mc1", category: "ChatKeyboard") - -/// UIKit table view controller with flipped orientation for chat-style scrolling -/// Newest messages appear at visual bottom, keyboard handling via native UIKit -@MainActor -final class ChatTableViewController: UITableViewController, UIContextMenuInteractionDelegate, UIGestureRecognizerDelegate where Item.ID == UUID { - // MARK: - Types - - private enum Section: Hashable { - case main - } - - private struct SnapshotApplyRequest { - var snapshot: NSDiffableDataSourceSnapshot - var animatingDifferences: Bool - var completion: (() -> Void)? - /// `true` when this request was issued by `reconfigureAllItems` (live theme switch). - /// The pending-slot coalescer carries this flag — and a re-applied - /// `reconfigureItems(allCurrentIDs)` — forward into any superseding request so the - /// reconfigure intent isn't silently dropped when latest-wins overwrites pendingSnapshot. - var reconfigureAll: Bool = false - } - - // MARK: - Properties - - private var items: [Item] = [] - /// O(1) lookup for items by ID (replaces O(n) first(where:) in cell provider) - private var itemsByID: [Item.ID: Item] = [:] - /// O(1) index lookup for scroll-to-item (replaces O(n) firstIndex(where:)) - private var itemIndexByID: [Item.ID: Int] = [:] - private var cellContentProvider: ((Item) -> CellContent)? - var defaultTableBackgroundColor: UIColor? - private var dataSource: UITableViewDiffableDataSource? - /// Snapshot scheduled while a previous apply was still running. Latest - /// wins: when a new request arrives mid-apply, it replaces this field - /// and the intermediate snapshot is skipped. Diffable data source - /// derives the visual result from the final snapshot alone, so - /// dropping intermediates is safe. - private var pendingSnapshot: SnapshotApplyRequest? - /// Completions from snapshot requests whose snapshot was superseded - /// before it landed. They still need to run because callers (notably - /// the pagination prepend path) use them to restore the anchor row's - /// viewport position after layout settles; the anchor row is part of - /// the superseding snapshot too, so measuring against the post-apply - /// layout is correct. Drained in order after the latest apply lands. - private var pendingCompletions: [() -> Void] = [] - - /// Bundled interaction/intent/apply/deferred axes for the scroll surface. - private(set) var scrollState: ChatScrollState = .idle - - /// Tracks scroll position relative to bottom - private(set) var isAtBottom: Bool = true - - /// Count of unread messages (messages added while scrolled up) - private(set) var unreadCount: Int = 0 - - /// ID of last message user has seen (for unread tracking) - private var lastSeenItemID: Item.ID? - - /// Callback when scroll state changes - var onScrollStateChanged: ((Bool, Int) -> Void)? - - /// Callback when user scrolls near the top (oldest messages). The closure receives a release - /// callback the consumer must invoke when pagination work completes (success or short-circuit) - /// so the request latch clears even when the view model's isLoadingOlder never visibly flips. - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - - /// Whether pagination is in progress (skip auto-scroll during pagination) - var isLoadingOlderMessages = false - - /// Suppresses duplicate onNearTop fires while the view model's isLoadingOlder propagates back through SwiftUI - private var isNearTopRequestInFlight = false - - /// Callback when a mention becomes visible. Returns whether the seen-state was - /// persisted; a false result means the id must not stay marked, so it can re-fire. - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - - /// Callback when a row receives a secondary (right) click from a pointer. On Mac the click - /// reaches the context-menu system (`UIContextMenuInteraction`); on iPad a trackpad or mouse - /// secondary click arrives as an indirect-pointer tap, handled by a dedicated recognizer. Touch - /// long-press is never routed here (it opens the sheet through the bubble's own gesture), so the - /// two paths can't double-trigger. Surfaces that leave this nil install neither, so they don't - /// suppress the native context menu in exchange for nothing. - var onSecondaryClick: ((Item) -> Void)? { - didSet { - installMacSecondaryClickIfNeeded() - installIPadSecondaryClickIfNeeded() - } - } - - /// Guards the one-time install of the Mac secondary-click interaction. - private var hasInstalledMacSecondaryClick = false - - /// Guards the one-time install of the iPad secondary-click recognizer. - private var hasInstalledIPadSecondaryClick = false - - /// Closure to check if an item contains an unseen self-mention - var isUnseenMention: ((Item) -> Bool)? - - /// Item ID of the new messages divider (for visibility tracking) - var dividerItemID: Item.ID? - - /// Callback when the divider row's visibility changes - var onDividerVisibilityChanged: ((Bool) -> Void)? - - /// Last reported divider visibility (change detection to avoid redundant callbacks) - private var lastDividerVisible: Bool? - - /// The full set of unseen self-mention ids, the source for the off-screen subset. - var unseenMentionIDs: [Item.ID] = [] - - /// Reports the unseen mentions not currently on screen, ordered oldest-to-newest as in - /// `unseenMentionIDs`. Their count drives the scroll-to-mention button; the last (newest) is - /// its scroll target. - var onOffscreenMentionsChanged: (([Item.ID]) -> Void)? - - /// Last reported off-screen mentions (change detection to avoid redundant callbacks) - private var lastReportedOffscreenMentionIDs: [Item.ID]? - - /// Tracks mention IDs that have already been reported as visible (prevents duplicate callbacks) - private var markedMentionIDs: Set = [] - - private var pendingScrollTargetID: Item.ID? - private var pendingScrollTask: Task? - private var checkVisibleMentionsTask: Task? - - /// Latest-wins buffer for updateItems calls received mid-drag. Applying a - /// snapshot mid-drag shifts contentOffset and fights the gesture; draining - /// on drag-end lets the offset adjust on settled content. - private var deferredItemsApply: (newItems: [Item], animated: Bool)? - - /// Coalesces the four scroll-tracking callbacks - /// (`updateIsAtBottom`, `checkVisibleMentions`, `checkDividerVisibility`, - /// `checkNearTop`) to at most one invocation per display frame. - /// `scrollViewDidScroll` only sets a flag; the display link's tick drains it. - private var hasPendingScrollObservation = false - private var scrollDisplayLink: CADisplayLink? - private let scrollDisplayLinkProxy = ChatScrollDisplayLinkProxy() - - /// Target item ID for programmatic scroll, derived from the active scroll intent. - private var scrollTargetItemID: Item.ID? { - if case let .toTarget(id) = scrollState.intent { return id } - return nil - } - - /// True when an auto-scroll-to-bottom was suppressed because the user was interacting. - /// Fired on drag end so messages arriving mid-drag aren't silently dropped. - var deferredScrollToBottomPending: Bool { - scrollState.deferredScroll != nil - } - - /// Count of messages deferred while user is interacting; counted as unread if they release scrolled away. - var deferredScrollMessageCount: Int { - scrollState.deferredScroll?.targetMessageCount ?? 0 - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Flip the table view for chat-style bottom anchoring - tableView.transform = CGAffineTransform(scaleX: 1, y: -1) - - // UIKit keyboard handling - bypasses SwiftUI bugs - tableView.keyboardDismissMode = .onDrag - - // Visual setup - tableView.separatorStyle = .none - // estimatedRowHeight intentionally unset: UIHostingConfiguration - // self-sizing produces an exact contentSize, so pagination prepends - // don't shift contentOffset as estimates are replaced with measurements. - - // Flipped table (scaleX: 1, y: -1) inverts top/bottom, so automatic - // content-inset adjustment applies safe-area padding to the wrong edges. - // SwiftUI's .safeAreaInset already handles the input bar, so disable UIKit's. - tableView.contentInsetAdjustmentBehavior = .never - - if #available(iOS 26.0, *) { - // Clear and non-opaque allows Liquid Glass effects on nav/input bars - tableView.backgroundColor = .clear - tableView.isOpaque = false - - // Scroll edge effects don't work correctly with flipped table transform. - // Hide both - the nav bar and input bar provide their own Liquid Glass blur. - tableView.topEdgeEffect.isHidden = true - tableView.bottomEdgeEffect.isHidden = true - } else { - tableView.backgroundColor = .systemBackground - } - tableView.allowsSelection = false - - // Register cell - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - - // Configure data source - configureDataSource() - - // Manual keyboard observation (UIKit auto-adjustment doesn't work in SwiftUI embed) - setupKeyboardObservers() - - // Coalesces scroll-tracking callbacks at display-frame cadence - setupScrollDisplayLink() - } - - /// Installs the Mac secondary-click interaction the first time a handler is set. Gated on Mac and - /// on a handler existing, so iPad uses its own recognizer instead and a surface without a handler - /// (a room with no actions sheet) leaves the native context menu untouched. - private func installMacSecondaryClickIfNeeded() { - guard ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledMacSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledMacSecondaryClick = true - tableView.addInteraction(UIContextMenuInteraction(delegate: self)) - } - - /// Installs the iPad secondary-click recognizer the first time a handler is set. A trackpad or - /// mouse secondary click arrives as an indirect-pointer event, not through the context-menu - /// system, so the Mac `UIContextMenuInteraction` never fires for it. `buttonMaskRequired` plus - /// the `shouldReceive` guard scope the recognizer to a sole secondary click, so a finger touch - /// (including the bubble long-press) is never delivered here and the two paths can't - /// double-trigger the sheet. - private func installIPadSecondaryClickIfNeeded() { - guard !ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledIPadSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledIPadSecondaryClick = true - let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryClick(_:))) - recognizer.buttonMaskRequired = .secondary - recognizer.cancelsTouchesInView = false - recognizer.delegate = self - tableView.addGestureRecognizer(recognizer) - } - - /// Resolves the model item under a point in the table view's coordinate space. - private func itemForRow(at point: CGPoint) -> Item? { - guard let indexPath = tableView.indexPathForRow(at: point), - let itemID = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[itemID] - } - - /// On the class, not an extension: a generic class can carry `@objc` conformance - /// only in its primary declaration. - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard let item = itemForRow(at: location) else { return nil } - onSecondaryClick?(item) - // No configuration suppresses the native menu, leaving the sheet the only actions surface. - return nil - } - - @objc private func handleSecondaryClick(_ recognizer: UITapGestureRecognizer) { - guard let item = itemForRow(at: recognizer.location(in: tableView)) else { return } - onSecondaryClick?(item) - } - - /// Scope the iPad recognizer to a sole secondary-button click. A direct touch reports an empty - /// button mask, so this rejects finger presses (and the bubble long-press), leaving an - /// indirect-pointer secondary click the only event that reaches `handleSecondaryClick`. The mask - /// is read from the event because the recognizer's own mask isn't updated with it yet here. - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { - event.buttonMask == .secondary - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } - - /// Swift 6.3.2 EarlyPerfInliner crashes (infinite recursion in - /// `isCallerAndCalleeLayoutConstraintsCompatible`) when optimizing this - /// generic UITableViewController subclass's deinit under -O. Opting the - /// deinit out of optimization sidesteps the crash without changing - /// runtime behavior. Drop the attribute once a future Swift release - /// fixes the underlying inliner bug. - @_optimize(none) - isolated deinit { - NotificationCenter.default.removeObserver(self) - scrollDisplayLink?.invalidate() - checkVisibleMentionsTask?.cancel() - } - - // MARK: - Keyboard Handling - - private func setupKeyboardObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow(_:)), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIResponder.keyboardWillChangeFrameNotification, - object: nil - ) - } - - private func keyboardFrameEnd(_ notification: Notification) -> CGRect? { - notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - } - - @objc private func keyboardWillHide(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillHide frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - @objc private func keyboardWillChangeFrame(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillChangeFrame frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - chatKeyboardLogger.debug( - "viewSafeAreaInsetsDidChange bottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - // MARK: - Scroll Coalescing - - /// Creates the `CADisplayLink` that drains coalesced scroll observations. - /// The link retains its target (the proxy), not the controller, so the - /// `deinit` path stays clean. Starts paused; `scrollViewDidScroll` unpauses. - private func setupScrollDisplayLink() { - scrollDisplayLinkProxy.onTick = { [weak self] in - self?.coalescedScrollTick() - } - let link = CADisplayLink( - target: scrollDisplayLinkProxy, - selector: #selector(ChatScrollDisplayLinkProxy.tick(_:)) - ) - link.add(to: .main, forMode: .common) - link.isPaused = true - scrollDisplayLink = link - } - - /// Drains pending scroll observations once per display frame. If a callback - /// re-arms the flag during processing, the link stays unpaused so the next - /// frame picks it up; otherwise the link pauses to avoid waking the run loop. - private func coalescedScrollTick() { - let hadWork = hasPendingScrollObservation - hasPendingScrollObservation = false - if hadWork { - updateIsAtBottom() - let visible = visibleItems() - checkVisibleMentions(visible: visible) - reportOffscreenMentions(visible: visible) - checkDividerVisibility() - checkNearTop() - } - if !hasPendingScrollObservation { - scrollDisplayLink?.isPaused = true - } - } - - #if DEBUG - /// Drains pending scroll observations synchronously. Production code - /// relies on the display link; this entry point lets unit tests verify - /// scroll callbacks without waiting for a real frame tick. - func flushScrollObservationsForTests() { - coalescedScrollTick() - } - #endif - - #if DEBUG - /// Exposes snapshot-derived scroll-row resolution so unit tests can assert that - /// scroll targets are sourced from the applied snapshot (nil-safe for ids not - /// yet applied) rather than the controller's leading items model. - func resolvedScrollRowForTests(id: Item.ID) -> IndexPath? { - snapshotRow(for: id) - } - #endif - - #if DEBUG - /// Advances the items model (items/itemsByID/itemIndexByID) without applying a - /// diffable snapshot, reproducing the model-ahead-of-snapshot state the apply-lag - /// window produces — where a model-derived row can exceed the applied row count - /// and abort scrollToRow. Tests use this to assert scroll-row resolution reads the - /// applied snapshot (nil for ids not yet applied, in-bounds for applied ids) - /// rather than the leading model. Mirrors the synchronous model mutation at the - /// top of updateItems. - func advanceItemsModelWithoutApplyingForTests(_ newItems: [Item]) { - items = newItems - itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) - itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) - } - #endif - - #if DEBUG - /// Identifiers reconfigured by every snapshot actually applied, accumulated across applies. - /// Lets unit tests assert that a targeted reconfigure survives the pending-slot coalescer - /// when its request is superseded mid-apply, reaching an applied snapshot. - private(set) var appliedReconfiguredItemIDsForTests: [Item.ID] = [] - - /// Forces the controller into the in-flight-apply state so subsequent `updateItems` - /// calls park in the pending slot instead of applying immediately. Lets tests open - /// the supersession window deterministically without a real animated apply in flight. - func beginApplyingForTests() { - scrollState.startApplying() - } - - /// Drains the pending snapshot queue, applying the surviving request synchronously - /// (no window means non-animated applies). Pairs with `beginApplyingForTests`. - func drainPendingForTests() { - drainSnapshotQueue() - } - #endif - - @objc private func keyboardWillShow(_ notification: Notification) { - guard let userInfo = notification.userInfo, - userInfo[UIResponder.keyboardFrameEndUserInfoKey] is CGRect else { - return - } - - chatKeyboardLogger.debug( - "keyboardWillShow frameEnd=\((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .null).debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - - let wasAtBottom = isAtBottom - - // SwiftUI handles frame changes for keyboard, so we don't add content inset. - // Just scroll to bottom after layout settles if we were at bottom. - if wasAtBottom { - // Set intent now to prevent scroll delegate from reacting to contentOffset - // oscillations during keyboard animation. Critical when content is shorter - // than visible area - the bouncing would otherwise cause isAtBottom to flip. - scrollState.startIntent(.toBottom) - - // Delay to let SwiftUI complete its layout pass - Task { @MainActor [weak self] in - try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) - self?.scrollToBottom(animated: true) - } - } - } - - // MARK: - Configuration - - func configure(cellContent: @escaping (Item) -> CellContent) { - cellContentProvider = cellContent - } - - // MARK: - Data Source - - /// Row for an item in the *applied* diffable snapshot, or nil if the snapshot - /// has not yet caught up with the controller's items model. updateItems mutates - /// items/itemIndexByID synchronously while the snapshot apply can lag (queued - /// behind an in-flight apply), so model-derived rows can exceed the table's - /// applied row count and abort scrollToRow. All scroll-row lookups must go - /// through here. Mirrors the snapshot-derived lookup in restorePrependAnchor. - private func snapshotRow(for id: Item.ID) -> IndexPath? { - dataSource?.indexPath(for: id) - } - - private func configureDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in - guard let self, - let item = itemsByID[itemID] else { - return UITableViewCell() - } - - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - // Flip cell back to normal orientation (must be cell, not contentView, - // because UIHostingConfiguration replaces contentView hierarchy) - cell.transform = CGAffineTransform(scaleX: 1, y: -1) - cell.backgroundColor = .clear - cell.selectionStyle = .none - // Allow long-press scale-up and shadow on message bubbles to extend - // past the cell frame instead of being clipped at cell edges. - cell.clipsToBounds = false - cell.contentView.clipsToBounds = false - - // Embed SwiftUI content - if let contentProvider = cellContentProvider { - if #available(iOS 26.0, *) { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - .background(.clear) - } else { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - } - } - - return cell - } - } - - // MARK: - Update Items - - /// When true, updateItems will skip auto-scroll (caller will scroll explicitly) - private var skipAutoScroll = false - - private func applySnapshot( - _ snapshot: NSDiffableDataSourceSnapshot, - animatingDifferences: Bool, - completion: (() -> Void)? = nil, - reconfigureAll: Bool = false - ) { - var request = SnapshotApplyRequest( - snapshot: snapshot, - animatingDifferences: animatingDifferences, - completion: completion, - reconfigureAll: reconfigureAll - ) - - if scrollState.isApplyingSnapshot { - if let existing = pendingSnapshot { - // Latest-wins for the snapshot itself, but preserve the superseded - // request's completion so prepend anchor restores still fire after - // the final apply lands. - if let superseded = existing.completion { - pendingCompletions.append(superseded) - } - // Carry the superseded snapshot's reconfigure intent forward into the - // incoming snapshot so latest-wins doesn't silently drop a repaint. A - // content-only reconfigure parked here would otherwise never reach an - // applied snapshot, stranding its cell on stale content. Only items still - // present in the incoming snapshot are carried; superseded-then-deleted - // items are gone regardless. - let carriedReconfigures = existing.snapshot.reconfiguredItemIdentifiers - .filter { request.snapshot.itemIdentifiers.contains($0) } - if !carriedReconfigures.isEmpty { - request.snapshot.reconfigureItems(carriedReconfigures) - } - // A reconfigure-all (live theme switch) additionally repaints rows added by - // the incoming snapshot, so extend the intent to its full identifier set. - if existing.reconfigureAll { - request.snapshot.reconfigureItems(request.snapshot.itemIdentifiers) - request.reconfigureAll = true - } - } - // The opposite ordering is intentionally not mirrored: an incoming reconfigure-all - // replacing a pending content snapshot adopts the reconfigure's (already-applied) - // identifier set, so the superseded snapshot's not-yet-applied items wait for the next - // apply. That is safe because a theme change also drives buildItems() with the full - // current set within a few frames, so the deferred rows reappear without a visible gap. - pendingSnapshot = request - return - } - - applySnapshotRequest(request) - } - - private func applySnapshotRequest(_ request: SnapshotApplyRequest) { - guard let dataSource else { - pendingSnapshot = nil - pendingCompletions.removeAll() - scrollState.endApplying() - return - } - - #if DEBUG - appliedReconfiguredItemIDsForTests.append(contentsOf: request.snapshot.reconfiguredItemIdentifiers) - #endif - - scrollState.startApplying() - let shouldAnimate = request.animatingDifferences && view.window != nil - - if shouldAnimate { - dataSource.apply(request.snapshot, animatingDifferences: true) { [weak self] in - Task { @MainActor [weak self] in - request.completion?() - self?.drainSnapshotQueue() - } - } - } else { - dataSource.apply(request.snapshot, animatingDifferences: false) - request.completion?() - drainSnapshotQueue() - } - } - - private func drainSnapshotQueue() { - scrollState.endApplying() - // Loop in case a new pending snapshot is enqueued while draining; - // each iteration applies the latest request and clears the field. - while let next = pendingSnapshot { - pendingSnapshot = nil - applySnapshotRequest(next) - } - // Fire superseded completions only when truly idle. An animated apply - // re-enters `scrollState.startApplying()` and finishes its drain in an - // async callback; firing now would run the completions before the - // final layout settles. The async callback will re-enter this method - // and reach the idle branch then. - if !scrollState.isApplyingSnapshot, !pendingCompletions.isEmpty { - let completions = pendingCompletions - pendingCompletions.removeAll() - for completion in completions { - completion() - } - } - } - - private struct PrependAnchor { - let itemID: Item.ID - /// rect.minY - contentOffset.y at capture time (viewport-relative position) - let distanceFromContentOffset: CGFloat - } - - private func capturePrependAnchor(in oldItems: [Item]) -> PrependAnchor? { - guard let visibleRows = tableView.indexPathsForVisibleRows, !visibleRows.isEmpty else { - return nil - } - let midIndexPath = visibleRows[visibleRows.count / 2] - let chronologicalIndex = oldItems.count - 1 - midIndexPath.row - guard chronologicalIndex >= 0, chronologicalIndex < oldItems.count else { return nil } - let rect = tableView.rectForRow(at: midIndexPath) - return PrependAnchor( - itemID: oldItems[chronologicalIndex].id, - distanceFromContentOffset: rect.minY - tableView.contentOffset.y - ) - } - - private func restorePrependAnchor(_ anchor: PrependAnchor) { - // Read the row from the data source's current snapshot, not the controller's mutable items, - // because a newer updateItems call may have overwritten items/itemIndexByID while the - // queued prepend apply (whose completion fires here) was waiting to drain. - guard let dataSource, - let indexPath = dataSource.indexPath(for: anchor.itemID) else { return } - tableView.layoutIfNeeded() - let newRect = tableView.rectForRow(at: indexPath) - CATransaction.begin() - CATransaction.setDisableActions(true) - tableView.contentOffset.y = newRect.minY - anchor.distanceFromContentOffset - CATransaction.commit() - } - - func updateItems(_ newItems: [Item], animated: Bool = true) { - if tableView.isDragging { - deferredItemsApply = (newItems, animated) - return - } - - let previousCount = items.count - let wasAtBottom = isAtBottom - let oldItems = items - items = newItems - - // Build O(1) lookup dictionaries - itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) - itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) - - // Detect prepend (pagination) vs append (new messages). A pure prepend changes the - // first id but not the last; requiring an unchanged tail keeps a combined prepend+append - // from being misclassified as prepend, which would skip the unread and auto-scroll branches. - let hasNewItems = newItems.count > previousCount - let wasPrepend = previousCount > 0 && hasNewItems - && oldItems.first?.id != newItems.first?.id - && oldItems.last?.id == newItems.last?.id - - // For prepends, capture a measured anchor row so we can restore the visible - // content's screen position after the snapshot apply changes contentSize. - let prependAnchor = wasPrepend ? capturePrependAnchor(in: oldItems) : nil - - // Apply snapshot with reversed order: newest-first for flipped table - // Row 0 = newest message → appears at visual bottom after flip - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(newItems.reversed().map(\.id)) - - // Find items that changed content (same ID, different hash). - // Without reconfiguring these, diffable data source won't update cells for items with same ID. - let oldItemsByID = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) - let changedIDs = newItems.compactMap { newItem -> Item.ID? in - guard let oldItem = oldItemsByID[newItem.id] else { return nil } - return oldItem != newItem ? newItem.id : nil - } - - // Two-phase apply to handle structural changes and content updates differently: - // 1. Structural changes (new/deleted items) - animate for smooth UX, except prepends - // 2. Content updates (status changes) - no animation to prevent flash - let hasStructuralChanges = newItems.count != oldItems.count || - Set(newItems.map(\.id)) != Set(oldItems.map(\.id)) - - // Skip the apply when nothing changed. Otherwise re-renders triggered by - // non-content state (e.g. ChatRenderState.isLoadingOlder toggling) reach - // applySnapshot without prepend-anchor protection and can shift - // contentOffset, producing a visible jump while scrolling. - if previousCount > 0, !hasStructuralChanges, changedIDs.isEmpty { - return - } - - // Prepends apply non-animated so anchor restoration in the apply completion runs - // against the post-apply layout, not against a coalesced animation in flight - let restoreClosure: (() -> Void)? = prependAnchor.map { anchor in - { [weak self] in self?.restorePrependAnchor(anchor) } - } - - if hasStructuralChanges { - let animateStructural = animated && previousCount > 0 && !wasPrepend - let structuralIsLastApply = changedIDs.isEmpty - applySnapshot( - snapshot, - animatingDifferences: animateStructural, - completion: structuralIsLastApply ? restoreClosure : nil - ) - - if !changedIDs.isEmpty { - var reconfigureSnapshot = snapshot - reconfigureSnapshot.reconfigureItems(changedIDs) - applySnapshot(reconfigureSnapshot, animatingDifferences: false, completion: restoreClosure) - } - } else if !changedIDs.isEmpty { - snapshot.reconfigureItems(changedIDs) - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } else { - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } - - // Handle unread tracking - if !wasAtBottom, previousCount > 0, hasNewItems, !wasPrepend { - // New messages arrived while scrolled up (not pagination) - let newMessageCount = newItems.count - previousCount - unreadCount += newMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } else if wasAtBottom, hasNewItems, !skipAutoScroll, scrollState.intent != .toBottom, !wasPrepend { - lastSeenItemID = newItems.last?.id - if scrollState.isUserDriven { - // Defer until drag ends — scrolling mid-drag fights the gesture and bounces - let accumulatedCount = (scrollState.deferredScroll?.targetMessageCount ?? 0) + (newItems.count - previousCount) - scrollState.scheduleDeferredScroll( - DeferredScroll(targetMessageCount: accumulatedCount) - ) - } else { - scrollToBottom(animated: animated && previousCount > 0) - } - } - - // Re-evaluate visible mentions and divider after layout settles. Both initialize - // assuming a scroll will correct them; without this an on-load viewport that already - // contains the divider or a mention is never reconciled until the first scroll. - scheduleVisibleMentionsRecheck() - - if let pendingID = pendingScrollTargetID { - schedulePendingScroll(for: pendingID, delay: ChatScrollConstants.pendingScrollInitialDelay) - } - } - - // MARK: - Scroll Control - - /// Called before updateItems when user sends a message. - /// Sets isAtBottom = true so updateItems won't increment unread. - func prepareForUserSend() { - isAtBottom = true - unreadCount = 0 - _ = scrollState.consumeDeferredScroll() - skipAutoScroll = true // Prevent updateItems from calling scrollToBottom (we'll do it explicitly) - } - - func scrollToBottom(animated: Bool) { - guard !items.isEmpty else { return } - - let alreadyAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon - - // Set state before scroll to prevent scroll delegate from overriding - isAtBottom = true - unreadCount = 0 - lastSeenItemID = items.last?.id - - // If already at bottom, just update state - no scroll needed. - // In a flipped table view with short content, scrollToRow miscalculates - // the target position and over-scrolls, pushing messages off screen. - if alreadyAtBottom { - scrollState.clearIntent() - onScrollStateChanged?(isAtBottom, unreadCount) - skipAutoScroll = false - return - } - - // Only update intent if not already toBottom (keyboardWillShow may have set it) - if scrollState.intent != .toBottom, animated { - scrollState.startIntent(.toBottom) - } - - // In flipped table with reversed data: row 0 = newest message - // Scroll row 0 to .top anchor (which is visual bottom in flipped table) - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: animated) - - if !animated { - scrollState.clearIntent() - } - - onScrollStateChanged?(isAtBottom, unreadCount) - - // Clear skipAutoScroll after explicit scroll (it was set by prepareForUserSend) - skipAutoScroll = false - } - - func scrollToItem(id: Item.ID, animated: Bool) { - // Use O(1) dictionary lookup instead of O(n) firstIndex - guard itemIndexByID[id] != nil else { return } - - // Set target intent so the computed scrollTargetItemID picks up the post-scroll reload target - // and so checkNearTop blocks pagination during the scroll-to-target animation. - scrollState.startIntent(.toTarget(id: id)) - pendingScrollTargetID = id - - pendingScrollTask?.cancel() - pendingScrollTask = nil - - if animated { - schedulePendingScroll(for: id, delay: ChatScrollConstants.scrollToTargetDelay) - } else { - pendingScrollTargetID = nil - centerItem(id: id, animated: false) - reloadTargetCell() - } - } - - func scrollToItemIfNotVisible(id: Item.ID, animated: Bool) { - guard let itemIndex = itemIndexByID[id] else { return } - let rowIndex = items.count - 1 - itemIndex - let indexPath = IndexPath(row: rowIndex, section: 0) - - if let visibleRows = tableView.indexPathsForVisibleRows, - visibleRows.contains(indexPath) { - return - } - - scrollToItem(id: id, animated: animated) - } - - /// Reloads the scroll target cell to fix UIHostingConfiguration layout timing issues - private func reloadTargetCell() { - guard let targetID = scrollTargetItemID else { return } - scrollState.clearIntent() - - // Force cell reconfiguration via snapshot reload - var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot() - if snapshot.itemIdentifiers.contains(targetID) { - snapshot.reloadItems([targetID]) - applySnapshot(snapshot, animatingDifferences: false) - } - } - - /// Reconfigures every current row in place so render-time, non-`MessageItem` styling - /// (the themed bubble fill) repaints on a live theme switch. `reconfigureItems` preserves - /// identity (no insert/delete, no scroll jump) and routes through the same serialized - /// `applySnapshot` path as every other apply. - func reconfigureAllItems() { - guard let dataSource else { return } - var snapshot = dataSource.snapshot() - guard !snapshot.itemIdentifiers.isEmpty else { return } - snapshot.reconfigureItems(snapshot.itemIdentifiers) - applySnapshot(snapshot, animatingDifferences: false, reconfigureAll: true) - } - - /// Returns true if the target was found in the applied snapshot and scrolled. - /// Returns false when the snapshot has not yet caught up to the items model, - /// letting the caller retry instead of silently dropping the scroll. - @discardableResult - private func centerItem(id: Item.ID, animated: Bool) -> Bool { - guard let indexPath = snapshotRow(for: id) else { return false } - tableView.layoutIfNeeded() - // Intent is already .toTarget(id:) from scrollToItem; no need to set again here. - tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) - return true - } - - private func schedulePendingScroll( - for id: Item.ID, - delay: Duration, - retriesRemaining: Int = ChatScrollConstants.pendingScrollMaxRetries - ) { - pendingScrollTask?.cancel() - pendingScrollTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: delay) - guard let self, !Task.isCancelled else { return } - guard pendingScrollTargetID == id, !scrollState.isUserDriven else { return } - pendingScrollTask = nil - if centerItem(id: id, animated: true) { - pendingScrollTargetID = nil - } else if retriesRemaining > 0 { - // The applied snapshot has not caught up to the items model yet. - // Keep the target armed and retry once it has had a chance to drain. - schedulePendingScroll( - for: id, - delay: ChatScrollConstants.pendingScrollRetryDelay, - retriesRemaining: retriesRemaining - 1 - ) - } else { - // No scrollToRow fired, so reloadTargetCell never clears the .toTarget - // intent; clear it here or checkNearTop will block pagination. - pendingScrollTargetID = nil - scrollState.clearIntent() - } - } - } - - // MARK: - Scroll Tracking - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - // Arm the coalescer; the display link's next tick drains the callbacks. - // Unpause only on the first hit of each burst — flipping `isPaused` is - // cheap but unnecessary when already running. - if !hasPendingScrollObservation { - hasPendingScrollObservation = true - scrollDisplayLink?.isPaused = false - } - } - - /// Re-checks mention and divider state after layout settles. The settle delay lets a snapshot - /// apply or a freshly loaded `unseenMentionIDs` finish before row positions are read. - func scheduleVisibleMentionsRecheck() { - checkVisibleMentionsTask?.cancel() - checkVisibleMentionsTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) - guard let self, !Task.isCancelled else { return } - let visible = visibleItems() - checkVisibleMentions(visible: visible) - reportOffscreenMentions(visible: visible) - checkDividerVisibility() - } - } - - /// Items backing the currently visible rows, resolved through the applied snapshot so a model - /// that is momentarily ahead of its snapshot can't map a visible row to the wrong item. - private func visibleItems() -> [Item] { - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return [] } - return visibleIndexPaths.compactMap { indexPath in - guard let id = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[id] - } - } - - /// Reports the unseen mentions not currently on screen, the subset that drives the - /// scroll-to-mention button; a mention already in view is excluded. - private func reportOffscreenMentions(visible: [Item]) { - guard let onOffscreenMentionsChanged else { return } - let offscreen: [Item.ID] - if unseenMentionIDs.isEmpty { - offscreen = [] - } else { - let visibleIDs = Set(visible.map(\.id)) - offscreen = unseenMentionIDs.filter { !visibleIDs.contains($0) } - } - if offscreen != lastReportedOffscreenMentionIDs { - lastReportedOffscreenMentionIDs = offscreen - onOffscreenMentionsChanged(offscreen) - } - } - - private func checkVisibleMentions(visible: [Item]) { - guard let isUnseenMention, let onMentionBecameVisible else { return } - - for item in visible { - // Report each mention once per session. Mark optimistically to debounce in-flight - // reports, then drop the mark if the async seen-persist fails, so a failed save does - // not strand a still-unread mention that a later scroll could otherwise re-fire. - if !markedMentionIDs.contains(item.id), isUnseenMention(item) { - let id = item.id - markedMentionIDs.insert(id) - Task { @MainActor [weak self] in - let persisted = await onMentionBecameVisible(id) - if !persisted { self?.markedMentionIDs.remove(id) } - } - } - } - } - - private func checkDividerVisibility() { - guard let dividerItemID, - let indexPath = snapshotRow(for: dividerItemID), - let onDividerVisibilityChanged else { - // No divider configured or not yet in the applied snapshot — report - // not visible if we previously reported visible. - if lastDividerVisible == true { - lastDividerVisible = false - onDividerVisibilityChanged?(false) - } - return - } - - let isVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false - - if isVisible != lastDividerVisible { - lastDividerVisible = isVisible - onDividerVisibilityChanged(isVisible) - } - } - - override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - } - drainDeferredItemsApply() - } - - override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - drainDeferredItemsApply() - } - - private func drainDeferredItemsApply() { - guard let deferred = deferredItemsApply else { return } - deferredItemsApply = nil - updateItems(deferred.newItems, animated: deferred.animated) - } - - private func fireDeferredScrollIfNeeded() { - guard let deferred = scrollState.consumeDeferredScroll() else { return } - if isAtBottom { - scrollToBottom(animated: true) - } else { - // User dragged away mid-message — the messages they didn't see become unread - unreadCount += deferred.targetMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } - } - - override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - // Capture whether the completed animation was a scroll-to-bottom before mutating intent. - let wasScrollingToBottom = scrollState.intent == .toBottom - if wasScrollingToBottom { - scrollState.clearIntent() - } - - // Reload target cell after scroll completes to fix UIHostingConfiguration layout timing. - // reloadTargetCell clears any .toTarget intent. - reloadTargetCell() - - if wasScrollingToBottom { - // We just finished a programmatic scroll-to-bottom - // Use larger threshold since animation might not land exactly at 0 - let atBottom = scrollView.contentOffset.y <= ChatScrollConstants.bottomLandingEpsilon - if atBottom { - // Confirm we're at bottom - this is authoritative - isAtBottom = true - unreadCount = 0 - onScrollStateChanged?(isAtBottom, unreadCount) - return - } - } - - // For user-initiated scrolls or if we didn't land at bottom, use normal check - updateIsAtBottom() - } - - private func updateIsAtBottom() { - // Don't override isAtBottom during programmatic scroll-to-bottom animation - // This prevents the scroll-to-bottom button from flickering when user sends a message - if scrollState.intent == .toBottom { - return - } - - // In flipped table, visual bottom = contentOffset.y near 0 - // Use small threshold to handle float imprecision - let newIsAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon - - if newIsAtBottom != isAtBottom { - isAtBottom = newIsAtBottom - onScrollStateChanged?(isAtBottom, unreadCount) - } - } - - private func finalizeScrollPosition() { - if isAtBottom { - // User scrolled to bottom, clear unread - unreadCount = 0 - lastSeenItemID = items.last?.id - onScrollStateChanged?(isAtBottom, unreadCount) - } - } - - /// Check if user has scrolled near the top (oldest messages) and trigger callback - private func checkNearTop() { - if scrollState.intent != .none || isLoadingOlderMessages || isNearTopRequestInFlight { - return - } - guard let visibleRows = tableView.indexPathsForVisibleRows, - let highestRow = visibleRows.map(\.row).max() else { return } - - let totalRows = items.count - let distanceFromTop = totalRows - highestRow - - // Trigger when within nearTopTriggerDistance messages of the oldest - if distanceFromTop <= ChatScrollConstants.nearTopTriggerDistance { - isNearTopRequestInFlight = true - onNearTop? { @MainActor [weak self] in - self?.isNearTopRequestInFlight = false - } - } - } - - override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - scrollState.enterDragging() - pendingScrollTargetID = nil - pendingScrollTask?.cancel() - pendingScrollTask = nil - } -} - -// MARK: - SwiftUI Wrapper - -/// SwiftUI wrapper for ChatTableViewController -struct ChatTableView: UIViewControllerRepresentable where Item.ID == UUID { - let items: [Item] - let cellContent: (Item) -> Content - /// Themed canvas color for themes that paint a canvas (Ember → black, paid themes → - /// asset-catalog tint); `nil` leaves the table's default system background untouched, so - /// themes without surfaces are unchanged. - var contentBackground: Color? - /// Active theme id (`Theme.id`). Drives a one-shot reconfigure of all rows on a theme change - /// so the render-time bubble fill (`\.appTheme.accentColor`, not part of `MessageItem`) repaints - /// even when no baked text changed — e.g. switching between two themes that share white text. - var themeID: String = Theme.default.id - /// Appearance fingerprint (light/dark + contrast). Like `themeID`, a change reconfigures all rows - /// once so identity colors that depend on the appearance repaint in place. - var appearanceToken: String = "" - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - var isUnseenMention: ((Item) -> Bool)? - /// The current unseen self-mention ids. Feeds the controller's off-screen subset and - /// triggers a recheck when the set changes; the per-row test goes through `isUnseenMention`. - var unseenMentionIDs: [Item.ID] = [] - /// Unseen mentions currently off screen, reported up from the controller; drives the - /// scroll-to-mention button's visibility, count, and scroll target. - @Binding var offscreenMentionIDs: [Item.ID] - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - var onSecondaryClick: ((Item) -> Void)? - var mentionTargetID: Item.ID? - @Binding var scrollToDividerRequest: Int - var dividerItemID: Item.ID? - @Binding var isDividerVisible: Bool - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - var isLoadingOlderMessages: Bool = false - - func makeUIViewController(context: Context) -> ChatTableViewController { - let controller = ChatTableViewController() - controller.defaultTableBackgroundColor = controller.tableView.backgroundColor - controller.configure { item in - cellContent(item) - } - // Callback set up in updateUIViewController - context.coordinator.lastScrollRequest = scrollToBottomRequest - controller.isUnseenMention = isUnseenMention - context.coordinator.lastMentionRequest = scrollToMentionRequest - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - return controller - } - - func updateUIViewController(_ controller: ChatTableViewController, context: Context) { - // Update cell content provider each render cycle so reconfigured cells - // get fresh closures (e.g., onRetry callback when message status changes) - controller.configure { item in - cellContent(item) - } - controller.tableView.backgroundColor = contentBackground.map(UIColor.init) ?? controller.defaultTableBackgroundColor - - // Repaint visible bubbles whose render-time accent fill is not part of `MessageItem`: - // a theme-id change reconfigures all rows in place once. The gate skips the first - // pass (no previous id) so appearance does not trigger a needless reconfigure. - let themeChanged = context.coordinator.lastThemeID.map { $0 != themeID } ?? false - let appearanceChanged = context.coordinator.lastAppearanceToken.map { $0 != appearanceToken } ?? false - if themeChanged || appearanceChanged { - controller.reconfigureAllItems() - } - context.coordinator.lastThemeID = themeID - context.coordinator.lastAppearanceToken = appearanceToken - - // Store current binding setters in coordinator (updated each render cycle) - // This ensures deferred callbacks always use fresh bindings - context.coordinator.setIsAtBottom = { [self] in isAtBottom = $0 } - context.coordinator.setUnreadCount = { [self] in unreadCount = $0 } - - // Controller callback defers to next MainActor yield via coordinator. - // SwiftUI blocks binding updates during updateUIViewController, so we must - // defer the update to after the current update cycle completes. - controller.onScrollStateChanged = { [weak coordinator = context.coordinator] atBottom, unread in - Task { @MainActor in - coordinator?.setIsAtBottom?(atBottom) - coordinator?.setUnreadCount?(unread) - } - } - - // Update mention detection closures - controller.isUnseenMention = isUnseenMention - controller.unseenMentionIDs = unseenMentionIDs - controller.onMentionBecameVisible = onMentionBecameVisible - controller.onSecondaryClick = onSecondaryClick - - context.coordinator.setOffscreenMentionIDs = { [self] in offscreenMentionIDs = $0 } - controller.onOffscreenMentionsChanged = { [weak coordinator = context.coordinator] ids in - // Defer: SwiftUI forbids binding writes during updateUIViewController. - Task { @MainActor in - coordinator?.setOffscreenMentionIDs?(ids) - } - } - - // unseenMentionIDs can load after the first render, with no item change to trigger the - // recheck inside updateItems. Re-run it here so the off-screen set reflects the new ids. - if context.coordinator.lastUnseenMentionIDs != unseenMentionIDs { - context.coordinator.lastUnseenMentionIDs = unseenMentionIDs - controller.scheduleVisibleMentionsRecheck() - } - - // Update divider visibility tracking - controller.dividerItemID = dividerItemID - context.coordinator.setIsDividerVisible = { [self] in isDividerVisible = $0 } - controller.onDividerVisibilityChanged = { [weak coordinator = context.coordinator] visible in - Task { @MainActor in - coordinator?.setIsDividerVisible?(visible) - } - } - - // Update pagination state - controller.onNearTop = onNearTop - controller.isLoadingOlderMessages = isLoadingOlderMessages - - // Check for scroll-to-mention request - let shouldScrollToMention = scrollToMentionRequest != context.coordinator.lastMentionRequest - var shouldScrollMentionToBottom = false - var mentionScrollTargetID: Item.ID? - - if shouldScrollToMention { - context.coordinator.lastMentionRequest = scrollToMentionRequest - mentionScrollTargetID = mentionTargetID - - let newestItemID = items.last?.id - shouldScrollMentionToBottom = ChatScrollToMentionPolicy.shouldScrollToBottom( - mentionTargetID: mentionTargetID, - newestItemID: newestItemID - ) - } - - // Check for scroll-to-divider request (new messages divider) - let shouldScrollToDivider = scrollToDividerRequest != context.coordinator.lastDividerScrollRequest - if shouldScrollToDivider { - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - } - - // Check for scroll-to-bottom request before updating items - // This ensures user sends don't trigger unread badge - let shouldForceScroll = scrollToBottomRequest != context.coordinator.lastScrollRequest - - if shouldForceScroll { - context.coordinator.lastScrollRequest = scrollToBottomRequest - // Mark as at bottom so updateItems won't increment unread - controller.prepareForUserSend() - } - - controller.updateItems(items) - - // Perform the scroll after items are updated - if shouldForceScroll { - controller.scrollToBottom(animated: true) - } else if shouldScrollToMention { - if shouldScrollMentionToBottom { - controller.scrollToBottom(animated: true) - } else if let targetID = mentionScrollTargetID { - controller.scrollToItem(id: targetID, animated: true) - } - } else if shouldScrollToDivider, let targetID = dividerItemID { - controller.scrollToItem(id: targetID, animated: true) - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - @MainActor - class Coordinator { - var lastScrollRequest: Int = 0 - var lastMentionRequest: Int = 0 - var lastDividerScrollRequest: Int = 0 - var lastThemeID: String? - var lastAppearanceToken: String? - var setIsAtBottom: ((Bool) -> Void)? - var setUnreadCount: ((Int) -> Void)? - var setIsDividerVisible: ((Bool) -> Void)? - var setOffscreenMentionIDs: (([Item.ID]) -> Void)? - var lastUnseenMentionIDs: [Item.ID] = [] - } -} diff --git a/MC1/Views/Chats/Components/ChatTiledCell.swift b/MC1/Views/Chats/Components/ChatTiledCell.swift new file mode 100644 index 00000000..01d0e81a --- /dev/null +++ b/MC1/Views/Chats/Components/ChatTiledCell.swift @@ -0,0 +1,25 @@ +import MessagingUI +import SwiftUI + +/// Adapts an existing chat bubble view into a `MessagingUI` cell. +/// +/// The bubble content is produced through `CellContentHost` rather than captured +/// directly: `TiledView` freezes its cell builder at creation, so reading the host +/// on every (re)configure is what lets recycled/reconfigured cells pick up the +/// current theme, link router, and action callbacks. +struct ChatTiledCell: TiledCellContent { + let item: Item + let host: CellContentHost + + func body(context: CellContext) -> some View { + host.content?(item) + } +} + +/// Stable reference the cell builder closes over so `ChatTiledView` can swap in a +/// fresh content closure each render without recreating the collection view. +/// Only ever touched on the main thread (SwiftUI body and the library's main-thread +/// cell callbacks), so it needs no isolation or `Sendable` conformance. +final class CellContentHost { + var content: ((Item) -> Content)? +} diff --git a/MC1/Views/Chats/Components/ChatTiledView.swift b/MC1/Views/Chats/Components/ChatTiledView.swift new file mode 100644 index 00000000..c8d3530e --- /dev/null +++ b/MC1/Views/Chats/Components/ChatTiledView.swift @@ -0,0 +1,135 @@ +import MessagingUI +import SwiftUI +import UIKit + +/// Chat scroll container backed by `MessagingUI.TiledView`. +/// +/// Replaces the bespoke flipped `UITableView`: the library provides stable +/// prepend (no scroll jump when paging older messages) and auto-scroll-to-bottom +/// on append. Consumers keep passing the same items array and cell-content +/// closure they used with the old table. +struct ChatTiledView: View where Item.ID == UUID { + let items: [Item] + let cellContent: (Item) -> Content + + /// Themed canvas color; `nil` leaves the background transparent so the + /// surrounding surface shows through. + var contentBackground: Color? + + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + + /// Bumped by callers to scroll to the visual bottom (e.g. on send). + var scrollToBottomRequest: Int = 0 + + /// Bumped by callers to jump to `scrollTargetID` (mention / reply / deeplink / divider). + var scrollToTargetRequest: Int = 0 + var scrollTargetID: Item.ID? + + /// One-shot item the list opens scrolled to on the first non-empty snapshot; + /// nil opens at the bottom. Drives the library's initial scroll target. + var initialScrollTargetID: Item.ID? + + /// Invoked when the top is reached, to page in older messages. + var onLoadOlder: (@MainActor @Sendable () async -> Void)? + + @Environment(\.appTheme) private var appTheme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @State private var scrollPosition: TiledScrollPosition + @State private var host = CellContentHost() + @State private var newestID: Item.ID? + + init( + items: [Item], + cellContent: @escaping (Item) -> Content, + contentBackground: Color? = nil, + isAtBottom: Binding, + unreadCount: Binding, + scrollToBottomRequest: Int = 0, + scrollToTargetRequest: Int = 0, + scrollTargetID: Item.ID? = nil, + initialScrollTargetID: Item.ID? = nil, + onLoadOlder: (@MainActor @Sendable () async -> Void)? = nil + ) { + self.items = items + self.cellContent = cellContent + self.contentBackground = contentBackground + _isAtBottom = isAtBottom + _unreadCount = unreadCount + self.scrollToBottomRequest = scrollToBottomRequest + self.scrollToTargetRequest = scrollToTargetRequest + self.scrollTargetID = scrollTargetID + self.initialScrollTargetID = initialScrollTargetID + self.onLoadOlder = onLoadOlder + // Open at the bottom by default; with an initial target present, hold off + // append-follow until the geometry callback re-derives it from the resting + // position, so an append during open does not fight the target. + _scrollPosition = State(initialValue: TiledScrollPosition( + autoScrollsToBottomOnAppend: initialScrollTargetID == nil, + scrollsToBottomOnReplace: true + )) + } + + var body: some View { + host.content = cellContent + + return TiledView(items: items, scrollPosition: $scrollPosition) { item in + ChatTiledCell(item: item, host: host) + } + .prependLoader(onLoadOlder.map { load in + .loader(perform: load) { + ProgressView().padding(.vertical, 8) + } + }) + .initialScrollTarget(id: initialScrollTargetID.map { AnyHashable($0) }, anchor: .top) + .onTiledScrollGeometryChange { geometry in + let atBottom = geometry.pointsFromBottom < ChatScrollConstants.bottomDetectionThreshold + if atBottom != isAtBottom { isAtBottom = atBottom } + if atBottom, unreadCount != 0 { unreadCount = 0 } + // Only follow appends while near the bottom; otherwise new messages + // accumulate as unread (counted in the onChange below). + scrollPosition.autoScrollsToBottomOnAppend = atBottom + } + .onDragIntoBottomSafeArea { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + .background(contentBackground ?? .clear) + .id(appearanceIdentity) + .overlay(alignment: .bottomTrailing) { + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollPosition.scrollTo(edge: .bottom) } + ) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: scrollToBottomRequest) { scrollPosition.scrollTo(edge: .bottom) } + .onChange(of: scrollToTargetRequest) { + guard let id = scrollTargetID else { return } + scrollPosition.scrollTo(id: id) + } + .onChange(of: items.last?.id, initial: true) { _, latest in + defer { newestID = latest } + guard !isAtBottom, let previous = newestID, + let previousIndex = items.firstIndex(where: { $0.id == previous }) else { return } + let appended = items.count - 1 - previousIndex + if appended > 0 { unreadCount += appended } + } + } + + /// Fingerprint of theme + appearance. A change fully rebuilds the list (via `.id`) so the + /// baked bubble colors repaint — the library does not reconfigure cells when only the + /// environment changes. + private var appearanceIdentity: String { + let appearance = AppearanceToken.make( + colorScheme: colorScheme, + contrast: colorSchemeContrast, + dynamicTypeSize: dynamicTypeSize + ) + return "\(appTheme.id)|\(appearance)" + } +} diff --git a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift deleted file mode 100644 index 48ff6444..00000000 --- a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift +++ /dev/null @@ -1,339 +0,0 @@ -import OSLog -import SwiftUI -import UIKit - -private let logger = Logger(subsystem: "com.mc1", category: "MessageBodyTextView") - -/// The single text/link rendering path for a message body. A `UITextView`-backed -/// `UIViewRepresentable` that replaces `Text(AttributedString)` and renders passively -/// (non-selectable): it installs no link, selection, or edit interactions, so the bubble's -/// `.onLongPressGesture` always wins (a long-press anywhere, including over a URL or coordinate, -/// opens the actions sheet) and a Mac secondary click falls through to the table's context-menu -/// interaction. Link taps are detected by a tap recognizer that hit-tests the `.link` attribute and -/// routes through the injected `OpenURLAction`. No message content is special-cased. -/// -/// The model stays `Sendable`/`Hashable`: this view takes the existing precomputed SwiftUI -/// `AttributedString` and derives the UIKit `NSAttributedString` at render time, on the main -/// actor with the live trait collection, rather than storing a non-`Sendable` `NSAttributedString` -/// on the payload. -struct MessageBodyTextView: UIViewRepresentable { - /// The single authored representation. The UIKit string is derived from this, never authored - /// in parallel, so every link kind's color/underline/bold/link has one source of truth. - let attributedString: AttributedString - - /// The base text color slot resolved by the bubble. Incoming bodies use `.primary` (mapped to - /// `UIColor.label` at bridge time); outgoing bodies use the filled-bubble text color, which - /// already bridges to a stable `UIColor`. - let baseColor: Color - - /// Dynamic Type token threaded from the SwiftUI environment so a category change invalidates - /// the Coordinator size cache. `updateUIView` runs on any environment change, so a reflow of - /// already-visible bubbles is driven from here in concert with the table's reconfigure. - let contentSizeCategoryToken: String - - @Environment(\.openURL) private var openURL - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeUIView(context: Context) -> BubbleBodyTextView { - let textView = BubbleBodyTextView(usingTextLayoutManager: false) - textView.configureForBubble() - - textView.isEditable = false - textView.isScrollEnabled = false - textView.adjustsFontForContentSizeCategory = true - - // Non-selectable: a passive renderer installs no link, selection, or edit interactions, so - // a long-press falls through to the bubble's gesture and a secondary click falls through to - // the table's context-menu interaction. Link taps are detected by the recognizer below. - textView.isSelectable = false - textView.isUserInteractionEnabled = true - - // A long-press must not become a drag or a text loupe; both would steal the gesture the - // bubble needs. Dropping the drop interaction prevents a long-press-into-drop hijack too. - textView.textDragInteraction?.isEnabled = false - if let dropInteraction = textView.textDropInteraction { - textView.removeInteraction(dropInteraction) - } - - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - textView.contentInsetAdjustmentBehavior = .never - - // Hug content in both axes so the hosting cell self-sizes from the measured text. - textView.setContentHuggingPriority(.required, for: .vertical) - textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // Single tap routes a link through the injected OpenURLAction; a tap that hits no link is - // not consumed (cancelsTouchesInView = false), so the bubble's tap and long-press still fire. - let tapRecognizer = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleTap(_:)) - ) - tapRecognizer.cancelsTouchesInView = false - // Off Mac, the text view is the recognizer's delegate so the bubble's long-press wins a - // contested press (see `BubbleBodyTextView`). On Mac the secondary click routes through the - // table's context-menu interaction, which this delegate must not disturb. - if !ProcessInfo.processInfo.isiOSAppOnMac { - tapRecognizer.delegate = textView - } - textView.addGestureRecognizer(tapRecognizer) - - apply(to: textView, context: context) - return textView - } - - func updateUIView(_ textView: BubbleBodyTextView, context: Context) { - apply(to: textView, context: context) - } - - /// Bridges the SwiftUI string to UIKit against the live trait collection, refreshes the live - /// `OpenURLAction` on the Coordinator (so a recycled cell picks up the current action), and - /// invalidates the size cache when the bridged content or Dynamic Type token changes. - private func apply(to textView: BubbleBodyTextView, context: Context) { - let coordinator = context.coordinator - coordinator.openURL = openURL - - let bridged = Self.makeAttributedText( - from: attributedString, - baseColor: baseColor, - traitCollection: textView.traitCollection - ) - - let contentHash = Self.contentHash( - attributedString: attributedString, - contentSizeCategoryToken: contentSizeCategoryToken - ) - if coordinator.contentHash != contentHash { - coordinator.contentHash = contentHash - coordinator.invalidateSizeCache() - } - - textView.attributedText = bridged - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - uiView: BubbleBodyTextView, - context: Context - ) -> CGSize? { - let width = proposal.width ?? UIView.layoutFittingCompressedSize.width - guard width.isFinite, width > 0 else { return nil } - - let coordinator = context.coordinator - let key = SizeCacheKey(width: Double(width), contentHash: coordinator.contentHash) - if let cached = coordinator.computedSizes[key] { - return cached - } - - let fitting = uiView.sizeThatFits( - CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) - ) - let resolved = CGSize(width: min(fitting.width, width), height: fitting.height) - - // A size-cache miss is the highest-attention surface: a stale height here would surface as a - // diffable reflow glitch, so each miss is logged for diagnosis. - logger.debug( - "size cache miss width=\(width, format: .fixed(precision: 1)) hash=\(coordinator.contentHash) measured=\(resolved.height, format: .fixed(precision: 1))" - ) - - // Writing the cache synchronously inside sizeThatFits would mutate Coordinator state during - // layout, which is the surface this project's diffable scroll crashes are tied to. Defer it, - // and store the entry only if the content discriminator still matches: if the bridged - // content or Dynamic Type token changed between this miss and the deferred block, the key is - // dead and writing it back would leak a stale entry. - DispatchQueue.main.async { - guard coordinator.contentHash == key.contentHash else { return } - coordinator.computedSizes[key] = resolved - } - return resolved - } - - // MARK: - Render-time bridging - - /// Base text style for the whole string. Dynamic Type comes for free because the run carries - /// the text style and `adjustsFontForContentSizeCategory` is on. - private static let baseFont = UIFont.preferredFont(forTextStyle: .body) - - /// Derives the UIKit `NSAttributedString` from the SwiftUI string for the given trait - /// collection. Pure and trait-driven so the bridging test can exercise it off-screen in both - /// a light and a dark/high-contrast collection. The `.link` runs carry across from the default - /// bridge; the font, the hashtag bold trait, the foreground color, and the underline are - /// overlaid because they either do not survive the default bridge into the UIKit attribute set - /// (the SwiftUI underline lands under a SwiftUI-scope key, not `NSAttributedString.Key`) or do - /// not resolve to a stable `UIColor` (`.primary`): - /// 1. Overlay the base font across the whole string (the SwiftUI string carries no font). - /// 2. Resolve hashtag bold from `inlinePresentationIntent == .stronglyEmphasized`. - /// 3. Resolve every foreground color against the live trait collection so `.primary` and any - /// gamut-derived mention/hashtag/contact color render correctly in dark/high-contrast - /// rather than falling through to grey. The `.primary` slot maps to `UIColor.label`. - /// 4. Re-apply the underline as the UIKit `.underlineStyle` for runs the SwiftUI string - /// underlined, since SwiftUI's underline does not bridge into the UIKit key. - /// 5. Re-apply the self-mention `.backgroundColor` for runs that carry one, resolved against - /// the live trait collection. SwiftUI's `.backgroundColor` is a SwiftUI-scope attribute that - /// does not survive the default bridge into the UIKit key, so the highlight is lost without it. - static func makeAttributedText( - from attributedString: AttributedString, - baseColor: Color, - traitCollection: UITraitCollection - ) -> NSAttributedString { - let result = NSMutableAttributedString(attributedString) - let fullRange = NSRange(location: 0, length: result.length) - - result.addAttribute(.font, value: baseFont, range: fullRange) - - for run in attributedString.runs { - let nsRange = NSRange(run.range, in: attributedString) - guard nsRange.length > 0 else { continue } - - let foreground = resolvedColor(run.foregroundColor ?? baseColor, traitCollection: traitCollection) - result.addAttribute(.foregroundColor, value: foreground, range: nsRange) - - if run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true { - result.addAttribute(.font, value: boldFont(from: baseFont), range: nsRange) - } - - if run.underlineStyle != nil { - result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) - } - - if let background = run.backgroundColor { - result.addAttribute( - .backgroundColor, - value: resolvedColor(background, traitCollection: traitCollection), - range: nsRange - ) - } - } - - return result - } - - /// Resolves a SwiftUI `Color` to a `UIColor` for the live trait collection. SwiftUI's `.primary` - /// does not bridge to a stable `UIColor`, so it is mapped to `UIColor.label` (matching - /// `BaseColorSlot.swiftUIColor`'s `.primary`); every other color resolves directly. - private static func resolvedColor(_ color: Color, traitCollection: UITraitCollection) -> UIColor { - if color == .primary { - return UIColor.label.resolvedColor(with: traitCollection) - } - return UIColor(color).resolvedColor(with: traitCollection) - } - - /// Body text style metrics shared so the bold (hashtag) variant scales with Dynamic Type in - /// lockstep with the surrounding `.body` runs, rather than freezing at a single point size. - private static let bodyMetrics = UIFontMetrics(forTextStyle: .body) - - /// Adds the bold symbolic trait while preserving `.body` Dynamic Type scaling. Building the bold - /// font at a fixed `pointSize` would strip the text-style metadata, so a hashtag run would not - /// rescale with `adjustsFontForContentSizeCategory` while its neighbours do. Wrapping the bold - /// descriptor in `UIFontMetrics(forTextStyle: .body)` keeps bold and body runs scaling together. - private static func boldFont(from font: UIFont) -> UIFont { - guard let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) else { - return font - } - return bodyMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) - } - - /// Combines the SwiftUI string's hash with the Dynamic Type token. The SwiftUI string already - /// folds in every contracted attribute (link, color, underline, bold), so its hash plus the - /// category token fully discriminates the bridged output for the size cache. - private static func contentHash(attributedString: AttributedString, contentSizeCategoryToken: String) -> Int { - var hasher = Hasher() - hasher.combine(attributedString) - hasher.combine(contentSizeCategoryToken) - return hasher.finalize() - } - - // MARK: - Coordinator - - @MainActor - final class Coordinator: NSObject { - /// `(width, contentHash)` size cache, held here rather than in `@State` to keep the cache - /// write off the SwiftUI layout path that this project's diffable crashes are tied to. - var computedSizes: [SizeCacheKey: CGSize] = [:] - - /// Discriminator for the current bridged content plus Dynamic Type token; a change clears - /// the size cache so a stale height cannot strand a reflowed bubble. - var contentHash: Int = 0 - - /// The live `OpenURLAction`, refreshed every `updateUIView` so a recycled cell taps through - /// the current action (the one `MentionTapHandler` installs with its suppression gate). - var openURL: OpenURLAction? - - func invalidateSizeCache() { - computedSizes.removeAll(keepingCapacity: true) - } - - /// Routes a tap on a linked glyph through the single injected `OpenURLAction`, so the - /// suppression gate and mention/contact/channel/map routing stay unchanged. A tap that hits - /// no link does nothing and is not consumed, so the bubble's own gestures still fire. - @objc func handleTap(_ recognizer: UITapGestureRecognizer) { - guard let textView = recognizer.view as? BubbleBodyTextView, - let url = textView.linkURL(at: recognizer.location(in: textView)) else { return } - openURL?(url) - } - } -} - -/// `(width, contentHash)` key for the body text view's size cache. `contentHash` discriminates the -/// bridged string plus the Dynamic Type token, so a width reuse across two different bodies, or the -/// same body at a new Dynamic Type size, cannot return a stale height. -struct SizeCacheKey: Hashable { - let width: Double - let contentHash: Int -} - -/// Passive `UITextView` subclass for a message body. It empties `linkTextAttributes` so `.link` -/// runs render in their authored `.foregroundColor` rather than the view's `tintColor`, and it -/// resolves link taps itself via `linkURL(at:)` rather than installing UIKit's link interaction. -/// The tap recognizer added in `makeUIView` uses this view as its delegate (off Mac) so the -/// bubble's long-press wins: `shouldRecognizeSimultaneouslyWith` denies simultaneity with a -/// long-press, so a sustained press opens the actions sheet instead of firing a link tap, while the -/// table's pan recognizer is left untouched. -final class BubbleBodyTextView: UITextView, UIGestureRecognizerDelegate { - /// Empties `linkTextAttributes` so `.link` runs render in their authored `.foregroundColor` - /// rather than the view's `tintColor`. Applied from `makeUIView`: `init(usingTextLayoutManager:)` - /// does not funnel through a `UITextView` designated-init override, so the policy is set here. - func configureForBubble() { - linkTextAttributes = [:] - } - - /// The link URL at a point in the view, or nil if the point is not on a linked glyph. Resolves - /// the glyph under the point with TextKit 1 and rejects the nearest-glyph over-hit that - /// `glyphIndex(for:in:)` returns for a tap past end-of-line, in trailing whitespace, or below - /// the last line, by requiring the point to lie inside that glyph's bounding rect. - func linkURL(at point: CGPoint) -> URL? { - guard textStorage.length > 0 else { return nil } - - let containerPoint = CGPoint( - x: point.x - textContainerInset.left, - y: point.y - textContainerInset.top - ) - let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer) - let glyphRect = layoutManager.boundingRect( - forGlyphRange: NSRange(location: glyphIndex, length: 1), - in: textContainer - ) - guard glyphRect.contains(containerPoint) else { return nil } - - let charIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) - guard charIndex < textStorage.length else { return nil } - - switch textStorage.attribute(.link, at: charIndex, effectiveRange: nil) { - case let url as URL: return url - case let string as String: return URL(string: string) - default: return nil - } - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UILongPressGestureRecognizer) - } -} diff --git a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageTextView.swift deleted file mode 100644 index 00fccc3e..00000000 --- a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import MC1Services -import SwiftUI - -/// Fragment-level wrapper over the body text renderer, driven by a typed `MessageTextPayload` -/// data carrier. Renders through `MessageBodyTextView` (a `UITextView`-backed representable) so -/// the bubble's long-press wins over link interaction while link taps still route; the SwiftUI -/// `Text(AttributedString)` path it replaced installed its own long-press link menu that -/// out-prioritized the bubble's gesture. -struct MessageTextView: View { - let text: MessageTextPayload - - /// Reflow of already-visible bubbles on a Dynamic Type change is driven primarily by the - /// AppearanceToken reconfigure re-host; this per-view token is a secondary discriminator that - /// guards the in-Coordinator size cache so a recycled view cannot reuse a height from another size. - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - var body: some View { - MessageBodyTextView( - attributedString: text.formatted ?? AttributedString(text.raw), - baseColor: text.baseColor.swiftUIColor, - contentSizeCategoryToken: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) - ) - } -} - -extension BaseColorSlot { - /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at - /// render time. Outgoing bubbles render on a filled background and need - /// white text; incoming bubbles use the system primary colour. Lives in - /// the MC1 layer because MC1Services intentionally has no SwiftUI - /// dependency. - var swiftUIColor: Color { - switch self { - case .outgoing: .white - case .incoming: .primary - } - } -} diff --git a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift index c40ca19a..94aa2b9f 100644 --- a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift +++ b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift @@ -2,15 +2,14 @@ import SwiftUI import UIKit /// A transparent tap catcher for an interactive bubble fragment (GIF, image, preview card). A quick -/// tap routes to `onTap`; a sustained press yields to the bubble's `.onLongPressGesture` so the -/// actions sheet opens. It replaces a SwiftUI `Button`, whose press gesture grabs the touch on -/// touch-down and cancels the bubble's long-press. +/// tap routes to `onTap`; a sustained press yields to the bubble's long-press so the actions sheet +/// opens. It replaces a SwiftUI `Button`, whose press gesture grabs the touch on touch-down and +/// cancels the bubble's long-press. /// -/// Mirrors the passive renderer's recognizer policy (`BubbleBodyTextView`): the `UITapGestureRecognizer` -/// does not consume touches (`cancelsTouchesInView = false`), and its delegate denies simultaneous -/// recognition with a `UILongPressGestureRecognizer` so the long-press wins a contested press. The -/// delegate is set only off Mac; on Mac the secondary click routes through the table's context-menu -/// interaction, which this delegate must not disturb. +/// The `UITapGestureRecognizer` does not consume touches (`cancelsTouchesInView = false`), and its +/// delegate denies simultaneous recognition with a `UILongPressGestureRecognizer` so the bubble's +/// long-press wins a contested press. The delegate is set only off Mac; on Mac the secondary click +/// routes through the table's context-menu interaction, which this delegate must not disturb. struct TapYieldingToLongPress: UIViewRepresentable { let onTap: () -> Void diff --git a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift index 0efac2ad..46963e06 100644 --- a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift +++ b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift @@ -38,7 +38,9 @@ struct LinkPreviewLoadingCard: View { private func unknownImageCard(url: URL?) -> some View { let host = url?.host ?? "" VStack(alignment: .leading, spacing: 0) { - shimmerHero(aspect: CGFloat(RichPreviewMetrics.fallbackAspect)) + // Prefer the remembered hero aspect for this URL so the shimmer reserves + // the final card footprint; the guess only applies to never-seen URLs. + shimmerHero(aspect: state.heroAspectHint.map { CGFloat($0) } ?? CGFloat(RichPreviewMetrics.fallbackAspect)) VStack(alignment: .leading, spacing: Self.textRowSpacing) { shimmerRow(height: Self.titleRowHeight, widthFraction: Self.titleRowWidthFraction) diff --git a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift index 5e839481..e576d0b5 100644 --- a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift +++ b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift @@ -31,6 +31,15 @@ extension View { /// `onFire`, drives the shared press state, and bumps the haptic trigger. /// Apply to every sub-view that should open the actions sheet; the visual /// response is rendered once by `messageBubbleLongPressEffect`. + /// + /// Built on `onLongPressGesture` rather than a hand-rolled `LongPressGesture`: + /// SwiftUI's modifier is scroll-aware, so a drag yields to the enclosing list's + /// pan and a quick tap falls through to a native `Text` link. A raw gesture + /// (`.gesture`/`.simultaneousGesture`/`.highPriorityGesture`) reserves the touch + /// and breaks either scrolling or link taps. + /// + /// On Mac a secondary (right) click fires the same action, since the tiled-view + /// backend no longer supplies a context-menu interaction to route it through. func messageBubbleLongPressGesture( isPressing: Binding, trigger: Binding, @@ -44,6 +53,7 @@ extension View { }, onPressingChanged: { isPressing.wrappedValue = $0 } ) + .onSecondaryClick(perform: onFire) } /// Renders the press-in shrink and haptic for a message bubble. Apply once to diff --git a/MC1/Views/Chats/Components/MessageBubbleSecondaryClick.swift b/MC1/Views/Chats/Components/MessageBubbleSecondaryClick.swift new file mode 100644 index 00000000..c9127dc5 --- /dev/null +++ b/MC1/Views/Chats/Components/MessageBubbleSecondaryClick.swift @@ -0,0 +1,64 @@ +import SwiftUI +import UIKit + +/// A transparent catcher for a secondary click (right click, trackpad two-finger click, or +/// control-click), used on Mac to open a message bubble's actions sheet — the shortcut a secondary +/// click implies, matching the sustained press that opens it elsewhere. +/// +/// It installs a `UIContextMenuInteraction`, the single interaction the "Designed for iPad" runtime +/// routes every secondary-click affordance through, and suppresses the system menu (returns `nil`), +/// firing our own action instead. A plain tap recognizer with `buttonMaskRequired = .secondary` +/// catches only a control-click — a trackpad two-finger click never arrives as a button event, only +/// as this context-menu request. +/// +/// Off Mac it is never installed: there is no secondary click, and a `UIContextMenuInteraction` +/// there would fire on long-press and fight the bubble's own long-press. +private struct SecondaryClickCatcher: UIViewRepresentable { + let onSecondaryClick: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onSecondaryClick: onSecondaryClick) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + view.addInteraction(UIContextMenuInteraction(delegate: context.coordinator)) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.onSecondaryClick = onSecondaryClick + } + + @MainActor + final class Coordinator: NSObject, UIContextMenuInteractionDelegate { + var onSecondaryClick: () -> Void + + init(onSecondaryClick: @escaping () -> Void) { + self.onSecondaryClick = onSecondaryClick + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + // Defer past this synchronous delegate call so presenting the sheet doesn't race the + // interaction's own teardown; return nil so no system menu appears. + DispatchQueue.main.async { [onSecondaryClick] in onSecondaryClick() } + return nil + } + } +} + +extension View { + /// Fires `perform` on a secondary click, on Mac only; a no-op elsewhere. + @ViewBuilder + func onSecondaryClick(perform: @escaping () -> Void) -> some View { + if ProcessInfo.processInfo.isiOSAppOnMac { + overlay { SecondaryClickCatcher(onSecondaryClick: perform) } + } else { + self + } + } +} diff --git a/MC1/Views/Chats/Components/MessageDayDividerView.swift b/MC1/Views/Chats/Components/MessageDayDividerView.swift index ebc1b1d9..7f6a66ba 100644 --- a/MC1/Views/Chats/Components/MessageDayDividerView.swift +++ b/MC1/Views/Chats/Components/MessageDayDividerView.swift @@ -1,46 +1,23 @@ import SwiftUI -/// Day separator shown once per calendar day in chat history, carrying the -/// relative day so the time markers (`MessageTimestampView`) stay time-only. -/// Wrapped in `TimelineView` so "Today" rolls to "Yesterday" at midnight. +/// Day separator shown once per calendar day in chat history. Rendered as a +/// centered, lined divider (matching `NewMessagesDividerView`) carrying the full +/// calendar date — e.g. "10 June 2026" — so each day's first message is clearly +/// anchored in time now that the per-cluster time marker lives inside the bubble. struct MessageDayDividerView: View { let date: Date var body: some View { - TimelineView(.everyMinute) { context in - Text(label(relativeTo: context.date)) - .font(.caption) + HStack { + VStack { Divider() } + Text(date.formatted(.dateTime.day().month(.wide).year())) + .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) - } - } - - private func label(relativeTo now: Date) -> String { - let calendar = Calendar.current - - if calendar.isDateInToday(date) { - return L10n.Chats.Chats.Timestamp.today - } else if calendar.isDateInYesterday(date) { - return L10n.Chats.Chats.Timestamp.yesterday - } - - // Weekday name for the past week, capped at 6 days so it cannot collide - // with today's weekday (which 7 days ago would share). - let dayOffset = calendar.dateComponents( - [.day], - from: calendar.startOfDay(for: date), - to: calendar.startOfDay(for: now) - ).day ?? 0 - - if (2...6).contains(dayOffset) { - return date.formatted(.dateTime.weekday(.wide)) - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - return date.formatted(.dateTime.month(.abbreviated).day()) - } else { - return date.formatted(.dateTime.month(.abbreviated).day().year()) + .fixedSize() + VStack { Divider() } } + .padding(.vertical, 4) } } @@ -54,11 +31,6 @@ struct MessageDayDividerView: View { .padding() } -#Preview("Weekday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) - .padding() -} - #Preview("Last Year") { MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) .padding() diff --git a/MC1/Views/Chats/Components/MessagePathFormatter.swift b/MC1/Views/Chats/Components/MessagePathFormatter.swift index 3e116acc..e4c6462d 100644 --- a/MC1/Views/Chats/Components/MessagePathFormatter.swift +++ b/MC1/Views/Chats/Components/MessagePathFormatter.swift @@ -1,11 +1,19 @@ import Foundation import MC1Services -/// Formats message routing path for display in message bubbles +/// Formats a message's routing path into a display-ready string for the bubble footer. +/// A path longer than the cap collapses its middle to a tight ellipsis (`head…tail`) so the +/// endpoints stay visible. Truncating here (rather than in the footer view) keeps the accessibility +/// label — which reads the same string — matched to what's on screen, and avoids a width-measuring +/// layout pass while scrolling. enum MessagePathFormatter { - /// Formats the routing path for display + /// Maximum hop IDs shown before the middle collapses to an ellipsis. + static let maxNodes = 4 + + /// Formats the routing path for display. /// - Parameter message: The message DTO containing path information - /// - Returns: Formatted path string (e.g., "Direct", "Flood", "A3,7F,42", or "A3,7F…B2,C1") + /// - Returns: `"Direct"`, `"Flood"`, the hop IDs (`"A3,7F,42"`), or a middle-collapsed + /// list (`"A3,7F…B2,C1"`) when longer than `maxNodes`. static func format(_ message: MessageDTO) -> String { if message.isDirectRouted { return L10n.Chats.Chats.Message.Path.direct @@ -24,13 +32,15 @@ enum MessagePathFormatter { return L10n.Chats.Chats.Message.Path.flood } - // Truncate if more than 6 nodes: show first 3 + ellipsis + last 3 - if nodes.count > 6 { - let first = nodes.prefix(3).joined(separator: ",") - let last = nodes.suffix(3).joined(separator: ",") - return "\(first)…\(last)" - } + return truncated(nodes) + } - return nodes.joined(separator: ",") + /// Joins the hop IDs, middle-collapsing to the first two and last two around a tight ellipsis + /// once the path exceeds `maxNodes`. + private static func truncated(_ nodes: [String]) -> String { + guard nodes.count > maxNodes else { return nodes.joined(separator: ",") } + let head = nodes.prefix(2).joined(separator: ",") + let tail = nodes.suffix(2).joined(separator: ",") + return "\(head)…\(tail)" } } diff --git a/MC1/Views/Chats/Components/MessageTimestampView.swift b/MC1/Views/Chats/Components/MessageTimestampView.swift deleted file mode 100644 index 0783a8e9..00000000 --- a/MC1/Views/Chats/Components/MessageTimestampView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -/// Centered, time-only marker shown between message clusters when a time gap -/// breaks grouping. The day is carried separately by `MessageDayDividerView`. -struct MessageTimestampView: View { - let date: Date - - var body: some View { - Text(date.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } -} - -#Preview { - VStack(spacing: 20) { - MessageTimestampView(date: Date()) - MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago - } - .padding() -} diff --git a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift b/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift deleted file mode 100644 index 3bc6d933..00000000 --- a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -/// Whether the user is actively driving the scroll surface. -enum InteractionState: Equatable { - case idle - case dragging -} - -/// What the controller wants the scroll surface to do, if anything. -enum ScrollIntent: Equatable { - case none - case toBottom - case toTarget(id: UUID) -} - -/// Whether a snapshot apply is in flight. -enum ApplyState: Equatable { - case idle - case applying -} - -/// Captures a scroll-to-bottom request that was deferred because the user was -/// dragging. Coexists with InteractionState.dragging. Resolved when interaction -/// ends. -struct DeferredScroll: Equatable { - let targetMessageCount: Int -} - -/// Container that bundles the three axes plus DeferredScroll for atomic -/// observation. Pure value-type; mutations go through helper methods. -struct ChatScrollState: Equatable { - var interaction: InteractionState - var intent: ScrollIntent - var apply: ApplyState - var deferredScroll: DeferredScroll? - - static let idle = ChatScrollState( - interaction: .idle, intent: .none, apply: .idle, deferredScroll: nil - ) - - /// True when the controller should not start a new scroll intent because - /// the user is dragging. The dragging axis is independent of intent — - /// existing intents continue. - var isUserDriven: Bool { - interaction == .dragging - } - - /// True when any programmatic scroll is in flight. Coexists with - /// interaction.dragging (e.g., reloadTargetCell applies a snapshot during - /// scroll-to-target). - var isApplyingSnapshot: Bool { - apply == .applying - } -} - -extension ChatScrollState { - mutating func enterDragging() { - interaction = .dragging - } - - mutating func endDragging() { - interaction = .idle - } - - mutating func startApplying() { - apply = .applying - } - - mutating func endApplying() { - apply = .idle - } - - mutating func startIntent(_ next: ScrollIntent) { - intent = next - } - - mutating func clearIntent() { - intent = .none - } - - /// Stores a scroll request to replay once the user stops dragging. - mutating func scheduleDeferredScroll(_ scroll: DeferredScroll) { - deferredScroll = scroll - } - - mutating func consumeDeferredScroll() -> DeferredScroll? { - defer { deferredScroll = nil } - return deferredScroll - } -} diff --git a/MC1/Views/Chats/Components/ScrollToBottomButton.swift b/MC1/Views/Chats/Components/ScrollToBottomButton.swift index 8404fb36..4352bc9c 100644 --- a/MC1/Views/Chats/Components/ScrollToBottomButton.swift +++ b/MC1/Views/Chats/Components/ScrollToBottomButton.swift @@ -11,9 +11,9 @@ struct ScrollToBottomButton: View { Image(systemName: "chevron.down") .font(.body.bold()) .frame(width: 44, height: 44) + .contentShape(.circle) } .buttonStyle(.plain) - .contentShape(.circle) .liquidGlassInteractive(in: .circle) .overlay(alignment: .topTrailing) { UnreadBadge(count: unreadCount, tint: .blue) diff --git a/MC1/Views/Chats/Components/ScrollToDividerButton.swift b/MC1/Views/Chats/Components/ScrollToDividerButton.swift deleted file mode 100644 index 04a85f0b..00000000 --- a/MC1/Views/Chats/Components/ScrollToDividerButton.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -/// Button to scroll to the new messages divider -struct ScrollToDividerButton: View { - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - Image(systemName: "chevron.up") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityLabel) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityHint) - } -} - -#Preview { - ScrollToDividerButton(onTap: {}) - .padding(50) -} diff --git a/MC1/Views/Chats/Components/ScrollToMentionButton.swift b/MC1/Views/Chats/Components/ScrollToMentionButton.swift deleted file mode 100644 index 3179bd2b..00000000 --- a/MC1/Views/Chats/Components/ScrollToMentionButton.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -/// Button to scroll to unread mentions -struct ScrollToMentionButton: View { - let unreadMentionCount: Int - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - Image(systemName: "at") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .overlay(alignment: .topTrailing) { - UnreadBadge(count: unreadMentionCount, tint: .red) - } - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityLabel) - .accessibilityValue(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityValue(unreadMentionCount)) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityHint) - } -} - -#Preview("With multiple") { - ScrollToMentionButton(unreadMentionCount: 5, onTap: {}) - .padding(50) -} - -#Preview("With one") { - ScrollToMentionButton(unreadMentionCount: 1, onTap: {}) - .padding(50) -} diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index 9d1ada68..56088fe5 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -78,22 +78,20 @@ struct UnifiedMessageBubble: View, Equatable { MessageDayDividerView(date: item.envelope.date) } - // The day divider already anchors the cluster in time, so a time-only - // marker directly beneath it would be redundant; suppress it there. - if item.grouping.showTimestamp, !item.grouping.showDayDivider { - MessageTimestampView(date: item.envelope.date) - } - HStack(alignment: .bottom, spacing: 4) { if item.envelope.isOutgoing { Spacer(minLength: bubbleRowOppositeEdgeMinInset) } - VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { + VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 2) { if !item.envelope.isOutgoing, configuration.showSenderName, item.grouping.showSenderName { SenderNameLabel(resolution: item.envelope.senderResolution, nameColor: senderColor) + // Indent the name to clear the bubble's rounded corner and + // leave breathing room above the bubble it labels. + .padding(.leading, 4) + .padding(.bottom, 3) } bubbleActionsLongPress( @@ -101,6 +99,7 @@ struct UnifiedMessageBubble: View, Equatable { item: item, layout: layout, bubbleColor: resolvedBubbleColor, + timeColor: footerTimeColor, callbacks: callbacks, imageResolver: imageResolver ) @@ -121,10 +120,6 @@ struct UnifiedMessageBubble: View, Equatable { ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in siblingFragmentView(fragment) } - - if item.footer.showStatusRow { - BubbleStatusRow(item: item, onRetry: callbacks.onRetry) - } } .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityMessageLabel) @@ -282,9 +277,19 @@ struct UnifiedMessageBubble: View, Equatable { ) } + /// Color for the send time rendered inside the bubble. `.secondary` reads + /// well on the gray incoming bubble; on the accent-colored outgoing bubble it + /// would wash out, so use a translucent outgoing-text color instead. + private var footerTimeColor: Color { + item.envelope.isOutgoing ? theme.outgoingTextColor.opacity(0.7) : .secondary + } + private var paddingTop: CGFloat { - if item.grouping.showDirectionGap { return 6 } - if item.grouping.showSenderName { return 4 } + // A direction switch (someone else replying) is the strongest visual + // break, then a new named sender within a channel. Same-sender follow-ups + // stay tightly stacked. + if item.grouping.showDirectionGap { return 14 } + if item.grouping.showSenderName { return 8 } return item.envelope.isOutgoing ? 1 : 2 } @@ -305,7 +310,7 @@ struct UnifiedMessageBubble: View, Equatable { } label += message.text if item.envelope.isOutgoing { - label += ", \(BubbleStatusRow.statusText(for: item))" + label += ", \(MessageStatusText.text(for: item.footer))" } if !item.envelope.isOutgoing { if item.footer.showHop { diff --git a/MC1/Views/Chats/ConversationListContent.swift b/MC1/Views/Chats/ConversationListContent.swift index 701f671f..2f69f938 100644 --- a/MC1/Views/Chats/ConversationListContent.swift +++ b/MC1/Views/Chats/ConversationListContent.swift @@ -12,6 +12,10 @@ struct ConversationListContent: View { } @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize private let viewModel: ChatViewModel private let favoriteConversations: [Conversation] @@ -77,6 +81,14 @@ struct ConversationListContent: View { } } } + // Once the list has loaded, eagerly warm the top conversations (up to the + // coordinator registry's LRU capacity) so tapping any of them opens straight + // to the settled bottom — including rows still below the fold that the lazy + // stack hasn't rendered yet, which the per-row dwell prewarm can't reach. + .task(id: hasLoadedOnce) { + guard hasLoadedOnce else { return } + await prewarmTopConversations() + } } private var loadingBody: some View { @@ -137,12 +149,53 @@ struct ConversationListContent: View { return ForEach(Array(ordered.enumerated()), id: \.element.id) { index, conversation in rowView(conversation, referenceDate: referenceDate) .transition(.opacity) + .task(id: conversation.id) { await prewarmOnDwell(conversation) } if index < ordered.count - 1 { Divider().padding(.leading, Self.rowSeparatorLeadingInset) } } } + /// Warms the shared coordinator for a conversation the user is dwelling on, so + /// tapping it opens straight to the settled bottom with no load flash. The + /// short delay (paired with the row's `.task` cancelling on disappear) means a + /// fast scroll past a row never pays the fetch/build cost — only rows the user + /// actually pauses on are prewarmed. Rooms have no coordinator; skipped. + private func prewarmOnDwell(_ conversation: Conversation) async { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + warm(conversation) + } + + /// Warms the top conversations on first load, up to the registry's LRU + /// capacity. Staggered so the per-conversation fetch and item build don't land + /// on the main actor in one burst; `prefetchConversation` no-ops for any that + /// are already warm (e.g. from a dwell prewarm). + private func prewarmTopConversations() async { + let ordered = favoriteConversations + otherConversations + for conversation in ordered.prefix(ChatCoordinatorRegistry.defaultCapacity) { + guard !Task.isCancelled else { return } + warm(conversation) + try? await Task.sleep(for: .milliseconds(30)) + } + } + + /// Kicks off the coordinator warm for one conversation. Rooms have no + /// coordinator; skipped. + private func warm(_ conversation: Conversation) { + guard let conversationType = ChatRoute(conversation: conversation).chatConversationType else { return } + appState.prefetchConversation( + conversationType, + envInputs: appState.chatEnvInputs( + for: conversationType, + themeID: theme.id, + isDark: colorScheme == .dark, + isHighContrast: colorSchemeContrast == .increased, + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + ) + } + @ViewBuilder private func rowView(_ conversation: Conversation, referenceDate: Date) -> some View { switch mode { diff --git a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift index 27922dd9..603df4da 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift @@ -1,11 +1,11 @@ import Foundation import SwiftUI -/// Builds the VoiceOver "open" actions for a message bubble's links. The passive body renderer -/// (`MessageBodyTextView`) installs no link interactions, so the body carries no VoiceOver link -/// rotor; these actions restore activation for every link kind. The list is derived from the same -/// precomputed `AttributedString` the body renders, so the actions can never diverge from the -/// visible links. +/// Builds the VoiceOver "open" actions for a message bubble's links. The bubble combines its +/// children into one accessibility element (`.accessibilityElement(children: .combine)`), so the +/// body carries no per-link VoiceOver rotor; these actions restore activation for every link kind. +/// The list is derived from the same precomputed `AttributedString` the body renders, so the +/// actions can never diverge from the visible links. /// /// Built during the bubble's `body`, which is gated by `UnifiedMessageBubble`'s `Equatable`-on- /// `item` seam, so it is never rebuilt while scrolling; and SwiftUI realizes the action descriptors diff --git a/MC1/Views/Chats/Navigation/ChatRoute.swift b/MC1/Views/Chats/Navigation/ChatRoute.swift index 167d4ce1..9f90e439 100644 --- a/MC1/Views/Chats/Navigation/ChatRoute.swift +++ b/MC1/Views/Chats/Navigation/ChatRoute.swift @@ -54,6 +54,19 @@ enum ChatRoute: Hashable { } } + /// The conversation payload to prefetch for this route, or `nil` for rooms, + /// which use a separate view with its own load path. + var chatConversationType: ChatConversationType? { + switch self { + case let .direct(contact): + .dm(contact) + case let .channel(channel): + .channel(channel) + case .room: + nil + } + } + var roomIsConnected: Bool? { guard case let .room(session) = self else { return nil } return session.isConnected diff --git a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift index 0baafdd0..af9f63f8 100644 --- a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift +++ b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift @@ -8,6 +8,10 @@ import SwiftUI /// share one instance. struct ChatsContentColumn: View { @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let viewModel: ChatViewModel @@ -103,6 +107,10 @@ struct ChatsContentColumn: View { if newRoute == nil { selectedRoute = nil lastSelectedRoomIsConnected = nil + } else if let newRoute { + // Sidebar taps bind selection directly (bypassing `navigate(to:)`), so warm the + // coordinator here — the universal hook for every split-view selection. + prefetch(newRoute) } } // Refresh the selected route's payload as the snapshot recomputes and re-run the @@ -142,6 +150,23 @@ struct ChatsContentColumn: View { appState.navigation.chatsSelectedRoute = route } + /// Warms the shared coordinator for the selected conversation before the detail + /// column swaps in, so the chat renders populated instead of jumping in a frame + /// later on a cold open. + private func prefetch(_ route: ChatRoute) { + guard let conversation = route.chatConversationType else { return } + appState.prefetchConversation( + conversation, + envInputs: appState.chatEnvInputs( + for: conversation, + themeID: theme.id, + isDark: colorScheme == .dark, + isHighContrast: colorSchemeContrast == .increased, + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + ) + } + private func clearNavigationIfActive(_ route: ChatRoute) { if appState.navigation.chatsSelectedRoute == route { selectedRoute = nil diff --git a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift index d6caa434..1f8c485a 100644 --- a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift @@ -9,11 +9,19 @@ struct ChatsSplitDetailContent: View { var body: some View { switch appState.navigation.chatsSelectedRoute { case let .direct(contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) + ChatConversationView( + conversationType: .dm(contact), + parentViewModel: viewModel, + coordinatorRegistry: appState.ensureChatCoordinatorRegistry() + ) + .id(contact.id) case let .channel(channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) + ChatConversationView( + conversationType: .channel(channel), + parentViewModel: viewModel, + coordinatorRegistry: appState.ensureChatCoordinatorRegistry() + ) + .id(channel.id) case let .room(session): RoomConversationView(session: session) .id(session.id) diff --git a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift index ec3f0250..be2d8624 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift @@ -17,12 +17,20 @@ struct ChatsStackLayout: View { Group { switch route { case let .direct(contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) + ChatConversationView( + conversationType: .dm(contact), + parentViewModel: viewModel, + coordinatorRegistry: appState.ensureChatCoordinatorRegistry() + ) + .id(contact.id) case let .channel(channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) + ChatConversationView( + conversationType: .channel(channel), + parentViewModel: viewModel, + coordinatorRegistry: appState.ensureChatCoordinatorRegistry() + ) + .id(channel.id) case let .room(session): RoomConversationView(session: session) diff --git a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift index 9791909f..75fc11de 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift @@ -2,6 +2,12 @@ import MC1Services import SwiftUI struct ChatsStackRootContent: View { + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let viewModel: ChatViewModel let filteredFavorites: [Conversation] let filteredOthers: [Conversation] @@ -29,7 +35,10 @@ struct ChatsStackRootContent: View { selectedFilter: $selectedFilter, hasLoadedOnce: hasLoadedOnce, emptyStateMessage: emptyStateMessage, - onNavigate: { navigationPath.append($0) }, + onNavigate: { route in + prefetch(route) + navigationPath.append(route) + }, onRequestRoomAuth: { roomToAuthenticate = $0 }, onDeleteConversation: onDeleteConversation ) @@ -44,4 +53,21 @@ struct ChatsStackRootContent: View { onHandlePendingRoomNavigation: onHandlePendingRoomNavigation )) } + + /// Warms the shared coordinator for the tapped conversation while the push + /// transition plays, so the chat renders populated instead of jumping in a + /// frame after the segue on a cold open. + private func prefetch(_ route: ChatRoute) { + guard let conversation = route.chatConversationType else { return } + appState.prefetchConversation( + conversation, + envInputs: appState.chatEnvInputs( + for: conversation, + themeID: theme.id, + isDark: colorScheme == .dark, + isHighContrast: colorSchemeContrast == .increased, + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + ) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index a0394a01..a1d47e7c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -4,20 +4,67 @@ import MC1Services extension ChatViewModel { // MARK: - Channel Messages - /// Load messages for a channel + /// Load messages for a channel: marks it active, syncs the device flood scope, + /// populates the coordinator, then clears unread state. Delegates coordinator + /// population to `primeInitialChannelMessages(for:)`; the unread/badge/notify + /// side effects here run only when that load succeeded. func loadChannelMessages(for channel: ChannelDTO) async { + // Track active channel for notification suppression + notificationService?.setActiveConversation( + channelIndex: channel.index, + channelRadioID: channel.radioID + ) + + let loaded = await primeInitialChannelMessages(for: channel) + + // Push the device flood scope after populating the timeline. A device + // command, so it runs only on real channel open — never during prefetch. + await syncFloodScope(for: channel) + + guard loaded else { return } + + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. + do { + try await dataStore?.clearChannelUnreadCount(channelID: channel.id) + try await dataStore?.clearChannelUnreadMentionCount(channelID: channel.id) + } catch { + logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") + } + syncCoordinator?.notifyConversationsChanged() + + // Update app badge + await notificationService?.updateBadgeCount() + } + + /// Populates the bound coordinator with the first page for `channel` and builds + /// its render items and mention senders — with no notification, flood-scope, + /// unread-clearing, or badge side effects. Safe to run before navigation to + /// warm the coordinator so the channel renders populated on the first frame + /// instead of popping in after the push transition. `loadChannelMessages` + /// layers the open-time side effects on top. Returns true when the fetch + /// succeeded. + @discardableResult + func primeInitialChannelMessages(for channel: ChannelDTO) async -> Bool { // Close the per-conversation empty-state gate while the fetch is // in flight. No-op when the coordinator is already past // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() + timelineWriter?.beginLoading() guard let dataStore else { - coordinator?.markLoaded() - return + timelineWriter?.markLoaded() + return false } - // Clear preview state only when switching to a different conversation - if currentChannel?.id != channel.id { + // Clear preview state only when switching away from a previously loaded + // conversation. A fresh view model has nothing to clear, and its cells + // may already be fetching previews for this same conversation (warm + // coordinators render before the load task runs), so clearing here would + // cancel those fetches mid-flight and strand their rows at `.loading`. + let isConversationSwitch = currentContact != nil + || (currentChannel != nil && currentChannel?.id != channel.id) + if isConversationSwitch { clearPreviewState() newMessagesDividerMessageID = nil dividerComputed = false @@ -27,41 +74,6 @@ extension ChatViewModel { currentChannel = channel currentContact = nil - // Track active channel for notification suppression - notificationService?.setActiveConversation( - channelIndex: channel.index, - channelRadioID: channel.radioID - ) - - // Sync the device's session-scoped flood key with the effective scope for this - // channel. The effective scope combines the per-channel preference with the - // device-level default — `.inherit` means "fall through to the default". - let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName - let desiredState: ChatViewModel.RegionScopeState = .pushed( - channel.floodScope, - deviceDefault: deviceDefault - ) - if lastSetRegionScope != desiredState, let session = sessionProvider() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: channel.floodScope, - deviceDefaultFloodScopeName: deviceDefault, - supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false - ) - do { - switch resolved { - case .unscoped: - try await session.setFloodScopeUnscoped() - case let .scope(scope): - try await session.setFloodScope(scope) - } - lastSetRegionScope = desiredState - } catch is CancellationError { - // Benign: a superseding load (reconnect / conversation switch) cancelled this one. - } catch { - logger.error("Failed to set flood scope: \(error.localizedDescription)") - } - } - isLoading = true // Dual-reset: this function is shared between passive load and user-initiated // retry paths, so both surfaces must clear at entry to avoid stale state. @@ -69,12 +81,15 @@ extension ChatViewModel { errorBannerMessage = nil // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + timelineWriter?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + var loaded = false do { - var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: ChatCoordinator.pageSize, offset: 0) + // Size the first page to include every unread message so the divider target is loaded. + let initialLimit = ChatCoordinator.initialPageSize(unreadCount: channel.unreadCount) + var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: initialLimit, offset: 0) let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } + timelineWriter?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } // Compute divider position before filtering, using unfiltered array computeDividerPosition(from: fetchedMessages, unreadCount: channel.unreadCount, isDM: false) @@ -82,9 +97,9 @@ extension ChatViewModel { // Hide sent reaction messages (unless failed) fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: false) - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) + // A full page means more history may exist above (compare to what we requested). + timelineWriter?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == initialLimit) } + timelineWriter?.replaceAll(fetchedMessages) buildChannelSenders(radioID: channel.radioID) buildItems() @@ -98,20 +113,7 @@ extension ChatViewModel { dataStore: dataStore ) } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearChannelUnreadCount(channelID: channel.id) - try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) - } catch { - logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() + loaded = true } catch is CancellationError { // Benign cancellation; the superseding load will refetch. } catch { @@ -120,8 +122,41 @@ extension ChatViewModel { // Ensures the empty-state gate opens even when the fetch threw — // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() + timelineWriter?.markLoaded() isLoading = false + return loaded + } + + /// Pushes the device's session-scoped flood key to match the effective scope + /// for `channel`. The effective scope combines the per-channel preference with + /// the device-level default — `.inherit` means "fall through to the default". + /// A no-op when the desired scope already matches the last one pushed. + private func syncFloodScope(for channel: ChannelDTO) async { + let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName + let desiredState: ChatViewModel.RegionScopeState = .pushed( + channel.floodScope, + deviceDefault: deviceDefault + ) + guard lastSetRegionScope != desiredState, let session = sessionProvider() else { return } + + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: channel.floodScope, + deviceDefaultFloodScopeName: deviceDefault, + supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false + ) + do { + switch resolved { + case .unscoped: + try await session.setFloodScopeUnscoped() + case let .scope(scope): + try await session.setFloodScope(scope) + } + lastSetRegionScope = desiredState + } catch is CancellationError { + // Benign: a superseding load (reconnect / conversation switch) cancelled this one. + } catch { + logger.error("Failed to set flood scope: \(error.localizedDescription)") + } } // MARK: - Channel Actions @@ -163,7 +198,7 @@ extension ChatViewModel { } catch { logger.error("enqueueChannel failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + timelineWriter?.applyStatusUpdate(messageID: message.id, status: .failed) sendErrorMessage = Self.copyForEnqueueFailure(error) } } @@ -211,7 +246,7 @@ extension ChatViewModel { // and so a stray ACK landing doesn't get clobbered if a future // refactor introduces channel-side delivery. _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .pending) - coordinator?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) + timelineWriter?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) let envelope = ChannelMessageEnvelope( messageID: message.id, @@ -226,7 +261,7 @@ extension ChatViewModel { } catch { logger.error("enqueueChannel retry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + timelineWriter?.applyStatusUpdate(messageID: message.id, status: .failed) sendErrorMessage = Self.copyForEnqueueFailure(error) } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift new file mode 100644 index 00000000..633fec79 --- /dev/null +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift @@ -0,0 +1,394 @@ +import MC1Services +import SwiftUI + +extension ChatViewModel { + // MARK: - Notification Level + + /// Sets notification level for a conversation with optimistic UI update + func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { + guard connectionStateProvider() == .ready else { return } + let originalLevel = conversation.notificationLevel + + // Capture once so the write and the badge update target the same container. + let dataStore = dataStore + let notificationService = notificationService + + // Optimistic UI update + updateConversationNotificationLevel(conversation, level: level) + + do { + switch conversation { + case let .direct(contact): + // Contacts still use boolean muted + try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) + case let .channel(channel): + try await dataStore?.setChannelNotificationLevel(channel.id, level: level) + case let .room(session): + try await dataStore?.setSessionNotificationLevel(session.id, level: level) + } + await notificationService?.updateBadgeCount() + } catch { + // Rollback on failure + updateConversationNotificationLevel(conversation, level: originalLevel) + logger.error("Failed to set notification level: \(error)") + } + } + + /// Toggles between muted and all (for swipe action) + func toggleMute(_ conversation: Conversation) async { + let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted + await setNotificationLevel(conversation, level: newLevel) + } + + /// Updates the notification level in the local conversations array + private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { + updateConversation( + conversation, + direct: { $0.with(isMuted: level == .muted) }, + channel: { $0.with(notificationLevel: level) }, + room: { $0.with(notificationLevel: level) } + ) + } + + // MARK: - Favorite + + /// Sets favorite state for a conversation with optimistic UI update + func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { + guard connectionStateProvider() == .ready else { return } + guard conversation.isFavorite != isFavorite else { return } + + // Reuse existing toggle logic + await toggleFavorite(conversation) + } + + /// Toggles favorite state for a conversation. + /// + /// For direct messages (contacts), this pushes the change to the device and waits + /// for confirmation before updating the UI. For channels and rooms (app-only), + /// this uses optimistic updates. + /// + /// - Parameters: + /// - conversation: The conversation to toggle + /// - disableAnimation: When true, disables SwiftUI List animations to prevent + /// conflicts with swipe action dismissal animations + func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { + guard connectionStateProvider() == .ready else { return } + let originalState = conversation.isFavorite + let newState = !originalState + + switch conversation { + case let .direct(contact): + // Contacts sync with device - wait for confirmation + togglingFavoriteID = contact.id + defer { togglingFavoriteID = nil } + + do { + try await contactService?.setContactFavorite(contact.id, isFavorite: newState) + // Device confirmed - update local UI + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + } catch { + logger.error("Failed to toggle contact favorite: \(error)") + } + + case let .channel(channel): + // Channels are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle channel favorite: \(error)") + } + + case let .room(session): + // Rooms are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle room favorite: \(error)") + } + } + } + + private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { + if disableAnimation { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) + } + } else { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) + } + } + + /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously + /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. + private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { + updateConversation( + conversation, + direct: { $0.with(isFavorite: isFavorite) }, + channel: { $0.with(isFavorite: isFavorite) }, + room: { $0.with(isFavorite: isFavorite) } + ) + } + + /// Locates the element backing `conversation` in its list, replaces it with the mapped value, + /// and republishes the snapshot. Collapses the per-list `firstIndex` lookup the optimistic + /// mutators would otherwise each repeat; each caller supplies only the differing transform. + private func updateConversation( + _ conversation: Conversation, + direct mapDirect: (ContactDTO) -> ContactDTO, + channel mapChannel: (ChannelDTO) -> ChannelDTO, + room mapRoom: (RemoteNodeSessionDTO) -> RemoteNodeSessionDTO + ) { + switch conversation { + case let .direct(contact): + if let index = conversations.firstIndex(where: { $0.id == contact.id }) { + conversations[index] = mapDirect(conversations[index]) + } + case let .channel(channel): + if let index = channels.firstIndex(where: { $0.id == channel.id }) { + channels[index] = mapChannel(channels[index]) + } + case let .room(session): + if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { + roomSessions[index] = mapRoom(roomSessions[index]) + } + } + recomputeSnapshot() + } + + // MARK: - Conversation List + + /// Clears all conversation data from the view model. + /// Called when the device is forgotten or removed so the list doesn't show stale entries. + func clearConversations() { + conversations = [] + channels = [] + roomSessions = [] + pendingRemovalIDs = [] + deletingIDs = [] + allContacts = [] + nicknamesByLoweredName = [:] + channelSenders = [] + channelSenderNames = [] + channelSenderOrder = [:] + contactNameSet = [] + lastMessageCache = [:] + recomputeSnapshot() + } + + /// True while an optimistic hide or a confirmation-gated radio delete is in flight for + /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. + func isDeletePending(_ id: UUID) -> Bool { + pendingRemovalIDs.contains(id) || deletingIDs.contains(id) + } + + /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't + /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. + func removeConversation(_ conversation: Conversation) { + pendingRemovalIDs.insert(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + conversations = conversations.filter { $0.id != contact.id } + case let .channel(channel): + channels = channels.filter { $0.id != channel.id } + case let .room(session): + roomSessions = roomSessions.filter { $0.id != session.id } + } + recomputeSnapshot() + } + } + + /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. + /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. + func restoreConversation(_ conversation: Conversation) { + pendingRemovalIDs.remove(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + if !conversations.contains(where: { $0.id == contact.id }) { + conversations.append(contact) + } + case let .channel(channel): + if !channels.contains(where: { $0.id == channel.id }) { + channels.append(channel) + } + case let .room(session): + if !roomSessions.contains(where: { $0.id == session.id }) { + roomSessions.append(session) + } + } + recomputeSnapshot() + } + } + + /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer + /// together. Purging matters because a stale reload could have re-added the contact while it + /// was masked, and a later recompute would then republish it; a re-created row still returns + /// via the next reload's fetch. + func confirmDirectRemoval(_ contact: ContactDTO) { + pendingRemovalIDs.remove(contact.id) + conversations.removeAll { $0.id == contact.id } + recomputeSnapshot() + } + + /// Load conversations for a device + func loadConversations(radioID: UUID) async { + guard let dataStore else { return } + + isLoading = true + errorBannerMessage = nil + + do { + conversations = try await dataStore.fetchConversations(radioID: radioID) + recomputeSnapshot() + } catch { + errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("loadConversations failed: \(error.localizedDescription)") + } + + hasLoadedOnce = true + isLoading = false + } + + /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the + /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. + /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. + @discardableResult + func requestConversationReload() -> Task? { + reloadTask?.cancel() + guard let radioID = currentRadioIDProvider() else { + reloadTask = nil + clearConversations() + return nil + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await performConversationReload(radioID: radioID) + } + reloadTask = task + return task + } + + /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. + /// No `await` may sit between the last `isCancelled` check and the assignment, so no other + /// reload can interleave a mismatched commit on the main actor. + private func performConversationReload(radioID: UUID) async { + guard let dataStore else { return } + isLoading = true + defer { isLoading = false } + + // Only fetchConversations sets the error banner; channel/room failures stay silent. + var banner: String? + let fetchedConversations: [ContactDTO]? + do { + fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) + } catch { + fetchedConversations = nil + banner = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") + } + if Task.isCancelled { return } + #if DEBUG + await reloadInterleaveHook?() + if Task.isCancelled { return } + #endif + + let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) + if Task.isCancelled { return } + let fetchedRooms = await (try? dataStore.fetchRemoteNodeSessions(radioID: radioID))? + .filter(\.isRoom) + if Task.isCancelled { return } + + if let fetchedConversations { conversations = fetchedConversations } + if let fetchedChannels { channels = fetchedChannels } + if let fetchedRooms { roomSessions = fetchedRooms } + errorBannerMessage = banner + reconcilePendingRemovals() + recomputeSnapshot() + hasLoadedOnce = true + + // Skip the trailing preview load if this reload was superseded. + if Task.isCancelled { return } + await loadLastMessagePreviews() + } + + // MARK: - Message Previews + + /// Get the cached last-message preview text for a conversation, keyed by its id. + func lastMessagePreview(id: UUID) -> String? { + lastMessageCache[id]?.text + } + + /// Load last message previews for all conversations. + /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). + func loadLastMessagePreviews() async { + guard let dataStore else { return } + + // Batch fetch contact message previews (single actor hop) + if !conversations.isEmpty { + do { + let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) + for contact in conversations { + // Find the last non-reaction message (skip outgoing reactions unless failed) + let lastMessage = contactMessages[contact.id]?.last { message in + guard message.direction == .outgoing, + ReactionParser.parseDM(message.text) != nil else { + return true + } + return message.status == .failed + } + + // Evict the cached preview only when no messages remain, so a cleared DM + // (still listed via lastMessageDate) shows "No messages"; a contact whose + // recent messages are all filtered-out reactions keeps its prior preview. + if let lastMessage { + lastMessageCache[contact.id] = lastMessage + } else if contactMessages[contact.id]?.isEmpty ?? true { + lastMessageCache.removeValue(forKey: contact.id) + } + } + } catch { + logger.warning("Failed to load contact message previews: \(error)") + } + } + + // Batch fetch channel message previews (single actor hop) + if !channels.isEmpty { + do { + let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } + let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) + for channel in channels { + guard let messages = channelMessages[channel.id] else { continue } + + // Filter out outgoing reactions (keep failed ones visible) + let lastMessage = messages.last { message in + if message.direction == .outgoing, + ReactionParser.parse(message.text) != nil, + message.status != .failed { + return false + } + return true + } + + if let lastMessage { + lastMessageCache[channel.id] = lastMessage + } else { + lastMessageCache.removeValue(forKey: channel.id) + } + } + } catch { + logger.warning("Failed to load channel message previews: \(error)") + } + } + } +} diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift index 1ebed75f..17850cd6 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift @@ -8,70 +8,43 @@ extension ChatViewModel { /// the incoming admission path after the receive-time prefetch resolves /// or hits its timeout, and from the outgoing send paths immediately /// after `createPendingMessage`. Preserves unread-counter math via the - /// `newItems.count` delta in `ChatTableView.updateItems`. + /// item-count delta observed by `ChatTiledView`. func appendMessageIfNew(_ message: MessageDTO) { - guard let coordinator else { return } + guard coordinator != nil, let timelineWriter else { return } let previous = messages.last // Synchronous append: coordinator append, render item insertion, and // channel sender bookkeeping all mutate Observable state on the main // actor in one call frame, so SwiftUI already invalidates dependent // views once per change cycle without an explicit transaction. - guard coordinator.append(message) else { return } + guard timelineWriter.append(message) else { return } let newItem = makeItem(for: message, previous: previous) - coordinator.appendRenderItem(newItem) + timelineWriter.appendRenderItem(newItem) if let senderName = message.senderNodeName, let radioID = currentChannel?.radioID { addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) } - // URL detection writes `cachedURLs[messageID]` from a background task - // and lands as its own invalidation cycle. - let messageID = message.id - let text = message.text - let generation = urlDetectionGeneration - Task { [weak self] in - guard let self else { return } - await updateURLForDisplayItem(messageID: messageID, text: text, generation: generation) - } + // URL detection and cache rehydration happen synchronously inside + // `makeItem` (see `seedPreviewStateIfNeeded`), so the appended row is + // already carrying its preview fragment. } - /// Update URL detection for a single message by ID. - /// Uses O(1) dictionary lookup to handle concurrent array modifications. - private func updateURLForDisplayItem(messageID: UUID, text: String, generation: UInt64) async { - let detectedURL = await Task.detached(priority: .userInitiated) { - LinkPreviewService.extractFirstURL(from: text) - }.value - - // Drop stale writes after a buildItems rebuild — Task.cancel only kills the latest chain link. - // Single-row rebuilds via rebuildDisplayItem(for:) do not write cachedURLs and need no - // generation gating; only this URL-detection writer does. - guard urlDetectionGeneration == generation else { return } - - // Gate every per-VM write on the message still being present so a - // delete between detection-start and detection-end can never seed - // orphan state that no cleanup path purges before conversation - // switch. - guard let coordinator, - let message = coordinator.messagesByID[messageID] else { - logger.warning("URL update for missing message id \(messageID)") - return - } - - cachedURLs[messageID] = detectedURL - - // Rehydrate decoded-image state from the singleton when this is a - // fresh chat-entry on a URL whose decode already completed in a - // prior session. Painting `.loaded` plus the dict entry in the - // same call frame means the next render skips the shimmer - // transition entirely. - rehydrateInlineImageStateIfCached(messageID: messageID, url: detectedURL) - rehydratePreviewStateIfCached(messageID: messageID, url: detectedURL) - - let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) - } + /// Synchronously detects the message's first URL and rehydrates preview / + /// inline-image state from the process-lifetime decoded caches, before the + /// build inputs are snapshotted. A fresh view model (every open, prewarm, + /// or refresh) starts with cold dictionaries while the shared coordinator's + /// items are already on screen; without this seed the first rebuild bakes + /// the link-preview fragment as `.idle` (a zero-height `EmptyView`) and the + /// list visibly reflows when async detection later restores the card. + /// Idempotent: the `cachedURLs` sentinel (present-but-nil marks "detected, + /// no URL") makes repeat calls a dictionary hit. + func seedPreviewStateIfNeeded(for message: MessageDTO) { + guard cachedURLs[message.id] == nil else { return } + let url = LinkPreviewService.extractFirstURL(from: message.text) + cachedURLs[message.id] = url + rehydrateInlineImageStateIfCached(messageID: message.id, url: url) + rehydratePreviewStateIfCached(messageID: message.id, url: url) } /// Seed `decodedImages` / `imageIsGIF` / `previewStates = .loaded` @@ -117,9 +90,7 @@ extension ChatViewModel { /// `ChatCoordinator.rebuildItems`, which performs the off-actor hop and /// applies on main only when the captured `renderStateID` still matches. func buildItems() { - guard let coordinator else { return } - urlDetectionGeneration &+= 1 - let urlGeneration = urlDetectionGeneration + guard let coordinator, let timelineWriter else { return } // Drop stale entries from the previous build before `makeBuildInputs` // re-inserts. Theme toggle and offline-state flip both rebuild items @@ -128,36 +99,26 @@ extension ChatViewModel { // current request key has changed. mapPreviewRequestIndex.removeAll() - var uncachedMessageIDs: [(UUID, String)] = [] let messagesSnapshot = coordinator.messages + // Drop formatted-text entries for messages no longer in the timeline + // (conversation switch, deletion). Guarded so it is a no-op during normal + // pagination, where the cache only ever grows toward the message count. + if formattedTextCache.count > messagesSnapshot.count { + let liveIDs = Set(messagesSnapshot.map(\.id)) + formattedTextCache = formattedTextCache.filter { liveIDs.contains($0.key) } + } + + // URL detection and decoded-cache rehydration run synchronously inside + // `makeBuildInputs` (see `seedPreviewStateIfNeeded`), so every row leaves + // this loop already carrying its preview fragment at a stable height. let inputs: [(MessageDTO, MessageBuildInputs)] = messagesSnapshot.enumerated().map { index, message in let previous: MessageDTO? = index > 0 ? messagesSnapshot[index - 1] : nil - - if cachedURLs[message.id] == nil, - previewStates[message.id] == nil, - loadedPreviews[message.id] == nil { - uncachedMessageIDs.append((message.id, message.text)) - } - return (message, makeBuildInputs(for: message, previous: previous)) } - let messagesToDetect = uncachedMessageIDs - - coordinator.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in - guard let self else { return } - if !messagesToDetect.isEmpty { - urlDetectionTask?.cancel() - urlDetectionTask = Task { [weak self] in - guard let self else { return } - for (messageID, text) in messagesToDetect { - guard !Task.isCancelled, urlDetectionGeneration == urlGeneration else { return } - await updateURLForDisplayItem(messageID: messageID, text: text, generation: urlGeneration) - } - } - } - decodeLegacyPreviewImages() + timelineWriter.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in + self?.decodeLegacyPreviewImages() } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index a812c401..1e2fc4f9 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -33,7 +33,7 @@ extension ChatViewModel { // restarting on a fresh item identity. No DB fetch — the // dispatcher writes the DB row before firing this case. withAnimation { - coordinator?.applyStatusUpdate( + timelineWriter?.applyStatusUpdate( messageID: messageID, status: status, roundTripTime: roundTripTime @@ -43,15 +43,15 @@ extension ChatViewModel { case let .messageRetrying(messageID, _, _): // Payload-bearing variant routed straight to the reload chokepoint; // not coalescer-eligible because attempt/maxAttempts are per-event. - coordinator?.enqueueReload(messageID: messageID) + timelineWriter?.enqueueReload(messageID: messageID) case let .messageResent(messageID), let .messageFailed(messageID): - coordinator?.enqueueReload(messageID: messageID) + timelineWriter?.enqueueReload(messageID: messageID) case let .heardRepeatRecorded(messageID, _), let .reactionReceived(messageID, _): - coordinator?.enqueueReload(messageID: messageID) + timelineWriter?.enqueueReload(messageID: messageID) case let .routingChanged(contactID, _): guard let current = currentContact, current.id == contactID else { return } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift index 59e3bb80..90bb2eaf 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift @@ -65,6 +65,7 @@ extension ChatViewModel { /// affected rows — hence it must be called on the main actor (the batch /// `buildItems()` path already does). func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { + seedPreviewStateIfNeeded(for: message) let flags = Self.computeDisplayFlags(for: message, previous: previous) let cachedURL = cachedURLs[message.id].flatMap(\.self) // Extension-based image classification, minus URLs the fetch path has @@ -77,23 +78,36 @@ extension ChatViewModel { let directURL = ImageURLClassifier.directImageURL(for: cachedURL) return store.aspect(for: directURL) ?? store.aspect(for: cachedURL) }() + // Remembered hero size for the link-preview card, keyed by the page URL + // (fetch paths persist it on every resolved preview). Distinct namespace + // from the inline-image lookup above, which keys by the direct image URL. + let previewHeroAspect: Double? = { + guard !isInlineImageURL, let cachedURL, + let store = inlineImageDimensionsStore else { return nil } + return store.aspect(for: cachedURL) + }() - let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default - let identityBackgroundLuminances = theme.avatarSurfaceLuminances( - colorScheme: envInputs.isDark ? .dark : .light, - contrast: envInputs.isHighContrast ? .increased : .standard - ) - - let formatted = MessageText.buildFormattedText( - text: message.text, - isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName, - isHighContrast: envInputs.isHighContrast, - outgoingTextColor: theme.outgoingTextColor, - hashtagColor: theme.hashtagColor, - identityGamut: theme.identityGamut, - identityBackgroundLuminances: identityBackgroundLuminances - ) + let formatted: (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?) + if let cached = formattedTextCache[message.id] { + formatted = cached + } else { + let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default + let identityBackgroundLuminances = theme.avatarSurfaceLuminances( + colorScheme: envInputs.isDark ? .dark : .light, + contrast: envInputs.isHighContrast ? .increased : .standard + ) + formatted = MessageText.buildFormattedText( + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: envInputs.currentUserName, + isHighContrast: envInputs.isHighContrast, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: identityBackgroundLuminances + ) + formattedTextCache[message.id] = formatted + } var isMapPreviewReady = false // Gate the snapshot index on the privacy toggle so the index stays empty for @@ -121,6 +135,7 @@ extension ChatViewModel { hasPreviewIconRef: decodedPreviewAssets[message.id]?.icon != nil, imageIsGIF: imageIsGIF[message.id] ?? false, inlineImageAspect: inlineImageAspect, + previewHeroAspect: previewHeroAspect, mapPreviewLatitude: formatted.mapCoordinate?.latitude, mapPreviewLongitude: formatted.mapCoordinate?.longitude, isMapPreviewReady: isMapPreviewReady, @@ -140,7 +155,7 @@ extension ChatViewModel { /// Single-message convenience that pairs `makeBuildInputs` with the pure /// `MessageFragmentBuilder`. Single-row callers (`appendMessageIfNew`, - /// `rebuildDisplayItem`, `updateURLForDisplayItem`) keep using this; the + /// `rebuildDisplayItem`) keep using this; the /// batch path in `buildItems()` calls `makeBuildInputs` on main and then /// invokes the builder off-actor with the resulting snapshot. func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift index 64b7998e..1c5b04d2 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift @@ -39,7 +39,7 @@ extension ChatViewModel { return } - coordinator?.applyStatusUpdate( + timelineWriter?.applyStatusUpdate( messageID: message.id, status: .pending, userInitiated: true @@ -74,7 +74,7 @@ extension ChatViewModel { } catch { logger.error("enqueueChannel sendAgain failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + timelineWriter?.applyStatusUpdate(messageID: message.id, status: .failed) sendErrorMessage = Self.copyForEnqueueFailure(error) } } else { @@ -102,7 +102,7 @@ extension ChatViewModel { return } - coordinator?.applyStatusUpdate( + timelineWriter?.applyStatusUpdate( messageID: message.id, status: .pending, userInitiated: true @@ -126,8 +126,8 @@ extension ChatViewModel { try await dataStore.deleteMessage(id: message.id) // Remove from all local collections - coordinator?.remove(messageID: message.id) - coordinator?.removeRenderItem(id: message.id) + timelineWriter?.remove(messageID: message.id) + timelineWriter?.removeRenderItem(id: message.id) // Clean up preview state for deleted message cleanupPreviewState(for: message.id) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index c7e3b00e..94b0e486 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -2,255 +2,7 @@ import MC1Services import SwiftUI extension ChatViewModel { - // MARK: - Notification Level - - /// Sets notification level for a conversation with optimistic UI update - func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { - guard connectionStateProvider() == .ready else { return } - let originalLevel = conversation.notificationLevel - - // Capture once so the write and the badge update target the same container. - let dataStore = dataStore - let notificationService = notificationService - - // Optimistic UI update - updateConversationNotificationLevel(conversation, level: level) - - do { - switch conversation { - case let .direct(contact): - // Contacts still use boolean muted - try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) - case let .channel(channel): - try await dataStore?.setChannelNotificationLevel(channel.id, level: level) - case let .room(session): - try await dataStore?.setSessionNotificationLevel(session.id, level: level) - } - await notificationService?.updateBadgeCount() - } catch { - // Rollback on failure - updateConversationNotificationLevel(conversation, level: originalLevel) - logger.error("Failed to set notification level: \(error)") - } - } - - /// Toggles between muted and all (for swipe action) - func toggleMute(_ conversation: Conversation) async { - let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted - await setNotificationLevel(conversation, level: newLevel) - } - - /// Updates the notification level in the local conversations array - private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { - switch conversation { - case let .direct(contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isMuted: level == .muted) - } - case let .channel(channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(notificationLevel: level) - } - case let .room(session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(notificationLevel: level) - } - } - recomputeSnapshot() - } - - // MARK: - Favorite - - /// Sets favorite state for a conversation with optimistic UI update - func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { - guard connectionStateProvider() == .ready else { return } - guard conversation.isFavorite != isFavorite else { return } - - // Reuse existing toggle logic - await toggleFavorite(conversation) - } - - /// Toggles favorite state for a conversation. - /// - /// For direct messages (contacts), this pushes the change to the device and waits - /// for confirmation before updating the UI. For channels and rooms (app-only), - /// this uses optimistic updates. - /// - /// - Parameters: - /// - conversation: The conversation to toggle - /// - disableAnimation: When true, disables SwiftUI List animations to prevent - /// conflicts with swipe action dismissal animations - func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { - guard connectionStateProvider() == .ready else { return } - let originalState = conversation.isFavorite - let newState = !originalState - - switch conversation { - case let .direct(contact): - // Contacts sync with device - wait for confirmation - togglingFavoriteID = contact.id - defer { togglingFavoriteID = nil } - - do { - try await contactService?.setContactFavorite(contact.id, isFavorite: newState) - // Device confirmed - update local UI - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - } catch { - logger.error("Failed to toggle contact favorite: \(error)") - } - - case let .channel(channel): - // Channels are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle channel favorite: \(error)") - } - - case let .room(session): - // Rooms are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle room favorite: \(error)") - } - } - } - - private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { - if disableAnimation { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } - } else { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } - } - - /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously - /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. - private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { - switch conversation { - case let .direct(contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isFavorite: isFavorite) - } - case let .channel(channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(isFavorite: isFavorite) - } - case let .room(session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(isFavorite: isFavorite) - } - } - recomputeSnapshot() - } - - // MARK: - Conversation List - - /// Clears all conversation data from the view model. - /// Called when the device is forgotten or removed so the list doesn't show stale entries. - func clearConversations() { - conversations = [] - channels = [] - roomSessions = [] - pendingRemovalIDs = [] - deletingIDs = [] - allContacts = [] - nicknamesByLoweredName = [:] - channelSenders = [] - channelSenderNames = [] - channelSenderOrder = [:] - contactNameSet = [] - lastMessageCache = [:] - recomputeSnapshot() - } - - /// True while an optimistic hide or a confirmation-gated radio delete is in flight for - /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. - func isDeletePending(_ id: UUID) -> Bool { - pendingRemovalIDs.contains(id) || deletingIDs.contains(id) - } - - /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't - /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. - func removeConversation(_ conversation: Conversation) { - pendingRemovalIDs.insert(conversation.id) - withAnimation(.snappy) { - switch conversation { - case let .direct(contact): - conversations = conversations.filter { $0.id != contact.id } - case let .channel(channel): - channels = channels.filter { $0.id != channel.id } - case let .room(session): - roomSessions = roomSessions.filter { $0.id != session.id } - } - recomputeSnapshot() - } - } - - /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. - /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. - func restoreConversation(_ conversation: Conversation) { - pendingRemovalIDs.remove(conversation.id) - withAnimation(.snappy) { - switch conversation { - case let .direct(contact): - if !conversations.contains(where: { $0.id == contact.id }) { - conversations.append(contact) - } - case let .channel(channel): - if !channels.contains(where: { $0.id == channel.id }) { - channels.append(channel) - } - case let .room(session): - if !roomSessions.contains(where: { $0.id == session.id }) { - roomSessions.append(session) - } - } - recomputeSnapshot() - } - } - - /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer - /// together. Purging matters because a stale reload could have re-added the contact while it - /// was masked, and a later recompute would then republish it; a re-created row still returns - /// via the next reload's fetch. - func confirmDirectRemoval(_ contact: ContactDTO) { - pendingRemovalIDs.remove(contact.id) - conversations.removeAll { $0.id == contact.id } - recomputeSnapshot() - } - - /// Load conversations for a device - func loadConversations(radioID: UUID) async { - guard let dataStore else { return } - - isLoading = true - errorBannerMessage = nil - - do { - conversations = try await dataStore.fetchConversations(radioID: radioID) - recomputeSnapshot() - } catch { - errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("loadConversations failed: \(error.localizedDescription)") - } - - hasLoadedOnce = true - isLoading = false - } + // MARK: - Contacts /// Load all contacts for mention autocomplete func loadAllContacts(radioID: UUID) async { @@ -265,96 +17,59 @@ extension ChatViewModel { } } - /// Load channels for a device - func loadChannels(radioID: UUID) async { - guard let dataStore else { return } - - do { - channels = try await dataStore.fetchChannels(radioID: radioID) - recomputeSnapshot() - } catch { - // Silently handle - channels are optional - } - } + // MARK: - Messages - /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the - /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. - /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. - @discardableResult - func requestConversationReload() -> Task? { - reloadTask?.cancel() - guard let radioID = currentRadioIDProvider() else { - reloadTask = nil - clearConversations() - return nil - } - let task = Task { @MainActor [weak self] in - guard let self else { return } - await performConversationReload(radioID: radioID) - } - reloadTask = task - return task - } + /// Load messages for a contact: marks the conversation active, populates the + /// coordinator, then clears unread state. Delegates the coordinator population + /// to `primeInitialMessages(for:)`; the unread/badge/notify side effects here + /// run only when that load succeeded. + func loadMessages(for contact: ContactDTO) async { + // Track active conversation for notification suppression + notificationService?.setActiveConversation(contactID: contact.id) - /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. - /// No `await` may sit between the last `isCancelled` check and the assignment, so no other - /// reload can interleave a mismatched commit on the main actor. - private func performConversationReload(radioID: UUID) async { - guard let dataStore else { return } - isLoading = true - defer { isLoading = false } + guard await primeInitialMessages(for: contact) else { return } - // Only fetchConversations sets the error banner; channel/room failures stay silent. - var banner: String? - let fetchedConversations: [ContactDTO]? + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. do { - fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) + try await dataStore?.clearUnreadCount(contactID: contact.id) + try await dataStore?.clearUnreadMentionCount(contactID: contact.id) } catch { - fetchedConversations = nil - banner = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") + logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") } - if Task.isCancelled { return } - #if DEBUG - await reloadInterleaveHook?() - if Task.isCancelled { return } - #endif - - let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) - if Task.isCancelled { return } - let fetchedRooms = await (try? dataStore.fetchRemoteNodeSessions(radioID: radioID))? - .filter(\.isRoom) - if Task.isCancelled { return } - - if let fetchedConversations { conversations = fetchedConversations } - if let fetchedChannels { channels = fetchedChannels } - if let fetchedRooms { roomSessions = fetchedRooms } - errorBannerMessage = banner - reconcilePendingRemovals() - recomputeSnapshot() - hasLoadedOnce = true + syncCoordinator?.notifyConversationsChanged() - // Skip the trailing preview load if this reload was superseded. - if Task.isCancelled { return } - await loadLastMessagePreviews() + // Update app badge + await notificationService?.updateBadgeCount() } - // MARK: - Messages - - /// Load messages for a contact - func loadMessages(for contact: ContactDTO) async { + /// Populates the bound coordinator with the first page for `contact` and builds + /// its render items — with no notification, unread-clearing, or badge side + /// effects. Safe to run before navigation to warm the coordinator so the + /// conversation renders populated on the first frame instead of popping in a + /// frame after the push transition. `loadMessages` layers the open-time side + /// effects on top. Returns true when the fetch succeeded. + @discardableResult + func primeInitialMessages(for contact: ContactDTO) async -> Bool { // Close the per-conversation empty-state gate while the fetch is // in flight. No-op when the coordinator is already past // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() + timelineWriter?.beginLoading() guard let dataStore else { - coordinator?.markLoaded() - return - } - - // Clear preview state only when switching to a different conversation - if currentContact?.id != contact.id { + timelineWriter?.markLoaded() + return false + } + + // Clear preview state only when switching away from a previously loaded + // conversation. A fresh view model has nothing to clear, and its cells + // may already be fetching previews for this same conversation (warm + // coordinators render before the load task runs), so clearing here would + // cancel those fetches mid-flight and strand their rows at `.loading`. + let isConversationSwitch = currentChannel != nil + || (currentContact != nil && currentContact?.id != contact.id) + if isConversationSwitch { clearPreviewState() newMessagesDividerMessageID = nil dividerComputed = false @@ -363,9 +78,6 @@ extension ChatViewModel { currentContact = contact currentChannel = nil - // Track active conversation for notification suppression - notificationService?.setActiveConversation(contactID: contact.id) - isLoading = true // Dual-reset: this function is shared between passive load and user-initiated // retry paths, so both surfaces must clear at entry to avoid stale state. @@ -373,12 +85,15 @@ extension ChatViewModel { errorBannerMessage = nil // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + timelineWriter?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + var loaded = false do { - var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: ChatCoordinator.pageSize, offset: 0) + // Size the first page to include every unread message so the divider target is loaded. + let initialLimit = ChatCoordinator.initialPageSize(unreadCount: contact.unreadCount) + var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: initialLimit, offset: 0) let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } + timelineWriter?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } // Compute divider position before filtering, using unfiltered array computeDividerPosition(from: fetchedMessages, unreadCount: contact.unreadCount, isDM: true) @@ -386,9 +101,9 @@ extension ChatViewModel { // Hide sent reaction messages (unless failed) fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: true) - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) + // A full page means more history may exist above (compare to what we requested). + timelineWriter?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == initialLimit) } + timelineWriter?.replaceAll(fetchedMessages) buildItems() @@ -401,20 +116,7 @@ extension ChatViewModel { dataStore: dataStore ) } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearUnreadCount(contactID: contact.id) - try await dataStore.clearUnreadMentionCount(contactID: contact.id) - } catch { - logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() + loaded = true } catch is CancellationError { // Benign cancellation; the superseding load will refetch. } catch { @@ -423,10 +125,13 @@ extension ChatViewModel { // Ensures the empty-state gate opens even when the fetch threw — // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() + timelineWriter?.markLoaded() isLoading = false + return loaded } + // MARK: - Drafts + /// Load any saved draft for the current contact /// Drafts are consumed (removed) after loading to prevent re-display /// If no draft exists, this method does nothing @@ -463,6 +168,8 @@ extension ChatViewModel { store.setDraft(composingText, for: id) } + // MARK: - Sending + /// Send a message to the current contact /// This is non-blocking - message is created and shown immediately, sent in background func sendMessage(text: String) async { @@ -491,84 +198,8 @@ extension ChatViewModel { } catch { logger.error("enqueueDM failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + timelineWriter?.applyStatusUpdate(messageID: message.id, status: .failed) sendErrorMessage = Self.copyForEnqueueFailure(error) } } - - /// Refresh messages for current contact - func refreshMessages() async { - guard let contact = currentContact else { return } - await loadMessages(for: contact) - } - - // MARK: - Message Previews - - /// Get the cached last-message preview text for a conversation, keyed by its id. - func lastMessagePreview(id: UUID) -> String? { - lastMessageCache[id]?.text - } - - /// Load last message previews for all conversations. - /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). - func loadLastMessagePreviews() async { - guard let dataStore else { return } - - // Batch fetch contact message previews (single actor hop) - if !conversations.isEmpty { - do { - let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) - for contact in conversations { - // Find the last non-reaction message (skip outgoing reactions unless failed) - let lastMessage = contactMessages[contact.id]?.last { message in - guard message.direction == .outgoing, - ReactionParser.parseDM(message.text) != nil else { - return true - } - return message.status == .failed - } - - // Evict the cached preview only when no messages remain, so a cleared DM - // (still listed via lastMessageDate) shows "No messages"; a contact whose - // recent messages are all filtered-out reactions keeps its prior preview. - if let lastMessage { - lastMessageCache[contact.id] = lastMessage - } else if contactMessages[contact.id]?.isEmpty ?? true { - lastMessageCache.removeValue(forKey: contact.id) - } - } - } catch { - logger.warning("Failed to load contact message previews: \(error)") - } - } - - // Batch fetch channel message previews (single actor hop) - if !channels.isEmpty { - do { - let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } - let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) - for channel in channels { - guard let messages = channelMessages[channel.id] else { continue } - - // Filter out outgoing reactions (keep failed ones visible) - let lastMessage = messages.last { message in - if message.direction == .outgoing, - ReactionParser.parse(message.text) != nil, - message.status != .failed { - return false - } - return true - } - - if let lastMessage { - lastMessageCache[channel.id] = lastMessage - } else { - lastMessageCache.removeValue(forKey: channel.id) - } - } - } catch { - logger.warning("Failed to load channel message previews: \(error)") - } - } - } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift index 5722db47..5940f3e2 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift @@ -10,7 +10,7 @@ extension ChatViewModel { guard !isLoadingOlder, hasMoreMessages else { return } guard let dataStore else { return } - coordinator?.updateRenderState { $0.with(isLoadingOlder: true) } + timelineWriter?.updateRenderState { $0.with(isLoadingOlder: true) } // Snapshot conversation context before any await — actor reentrancy // means currentContact/currentChannel can change during suspensions @@ -35,13 +35,13 @@ extension ChatViewModel { offset: currentOffset ) } else { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + timelineWriter?.updateRenderState { $0.with(isLoadingOlder: false) } return } // Use unfiltered count to determine if more messages exist let unfilteredCount = olderMessages.count - coordinator?.updateRenderState { current in + timelineWriter?.updateRenderState { current in current.with( hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, totalFetchedCount: current.totalFetchedCount + unfilteredCount @@ -60,9 +60,9 @@ extension ChatViewModel { // Prepend older messages (they're chronologically earlier). // Re-run same-sender reordering across the page boundary to handle // clusters that were split between the existing and newly loaded pages. - coordinator?.prepend(olderMessages) + timelineWriter?.prepend(olderMessages) let reordered = MessageDTO.reorderSameSenderClusters(messages) - coordinator?.replaceMessagesPreservingByID(reordered) + timelineWriter?.replaceMessagesPreservingByID(reordered) // Register senders from the older page; without this, scrolling // back to a sender who only appears in older pages leaves them @@ -75,13 +75,15 @@ extension ChatViewModel { } } - buildItems() + // Clear the spinner before building items, not after. `updateRenderState` + // bumps the coordinator's `renderStateID`; doing it after `buildItems()` + // invalidates the just-launched off-main build on apply, forcing a full + // duplicate rebuild of the entire timeline. The prepended messages are + // already in the canonical array, so the spinner can retire now, and the + // slow reaction indexing below never gates it. + timelineWriter?.updateRenderState { $0.with(isLoadingOlder: false) } - // Clear the spinner now that the prepended messages are visible. - // Reaction indexing below awaits the actor and can take many - // hundreds of milliseconds on a busy channel; gating the spinner - // through it leaves pagination feeling locked-up. - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + buildItems() // Index older channel messages for reaction matching and process pending reactions if let channel, @@ -106,7 +108,7 @@ extension ChatViewModel { } } catch { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + timelineWriter?.updateRenderState { $0.with(isLoadingOlder: false) } errorBannerMessage = L10n.Chats.Chats.Error.loadOlderMessagesFailed logger.error("Failed to load older messages: \(error)") } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift index 6a2d70e9..9ce79368 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift @@ -106,7 +106,15 @@ extension ChatViewModel { /// Request preview fetch for a message (called when cell becomes visible) func requestPreviewFetch(for messageID: UUID) { - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + recoverOrphanedLoadingState(for: messageID) + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { + // The cell asked for a fetch this view model considers settled: the + // render item desynced from this state dictionary. Rebake the row so + // it repairs in one frame instead of shimmering forever; loop-free + // because `updateRenderItem` no-ops when the rebaked item is equal. + rebuildDisplayItem(for: messageID) + return + } guard let url = cachedURLs[messageID].flatMap(\.self) else { return } let isChannel = currentChannel != nil @@ -141,12 +149,14 @@ extension ChatViewModel { // Check if task was cancelled (message scrolled away or conversation changed) guard !Task.isCancelled else { previewFetchTasks.removeValue(forKey: messageID) + resetLoadingStateAfterCancellation(for: messageID) return } // Update state based on result switch result { case let .loaded(dto): + persistHeroAspect(from: dto, requestedURL: url) await decodeAndStorePreviewImages(from: dto, for: messageID) previewStates[messageID] = .loaded loadedPreviews[messageID] = dto @@ -179,6 +189,7 @@ extension ChatViewModel { switch result { case let .loaded(dto): + persistHeroAspect(from: dto, requestedURL: url) await decodeAndStorePreviewImages(from: dto, for: messageID) previewStates[messageID] = .loaded loadedPreviews[messageID] = dto @@ -191,6 +202,37 @@ extension ChatViewModel { rebuildDisplayItem(for: messageID) } + /// Persist the preview hero's aspect ratio keyed by the message's page URL + /// (the key `makeBuildInputs` looks up), so the next build's loading shimmer + /// reserves the final card footprint instead of guessing `fallbackAspect`. + private func persistHeroAspect(from dto: LinkPreviewDataDTO, requestedURL: URL) { + guard let width = dto.imageWidth, let height = dto.imageHeight, + let store = inlineImageDimensionsStore else { return } + Task { + await store.save(url: requestedURL, size: CGSize(width: width, height: height)) + } + } + + /// Warm link-preview metadata (and inline-image dimensions) for the newest + /// page rows, so a later open builds cards synchronously from the caches at + /// their final height. Called by the background prime paths (navigation + /// prefetch, arrival-time refresh); a no-op without a configured prefetcher + /// or with previews off. `LinkPreviewCache` dedups in-flight and cached + /// URLs, so repeat calls cost one dictionary hit per URL. + func prewarmRecentPreviews(limit: Int = 10) async { + guard let prefetcher, envInputs.previewsEnabled else { return } + let isChannel = currentChannel != nil + let allowImageProbes = linkPreviewPreferences.shouldAutoResolve(isChannelMessage: isChannel) + for message in messages.suffix(limit) + where !LinkPreviewService.extractAllURLs(in: message.text).isEmpty { + await prefetcher.prefetch( + urlsIn: message.text, + isChannelMessage: isChannel, + allowImageProbes: allowImageProbes + ) + } + } + /// Decode preview hero image and icon off the main thread and store results private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { async let heroResult: UIImage? = { @@ -221,9 +263,9 @@ extension ChatViewModel { /// Update a message in place and rebuild its display item. func updateMessage(id: UUID, mutation: (inout MessageDTO) -> Void) { - guard let coordinator, + guard let coordinator, let timelineWriter, coordinator.messagesByID[id] != nil else { return } - coordinator.update(messageID: id, mutation) + timelineWriter.update(messageID: id, mutation) rebuildDisplayItem(for: id) } @@ -236,11 +278,35 @@ extension ChatViewModel { return } let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in + timelineWriter?.updateRenderItem(id: messageID) { _ in makeItem(for: message, previous: previous) } } + /// A cancelled fetch that already wrote `.loading` must not leave it + /// behind: `.loading` bakes with a nil fetch-task id, so the cell's task + /// never re-fires and the row shimmers forever. Reset to `.idle` and + /// rebake so the row re-arms its fetch. After a conversation-switch clear + /// the state entry is already gone and this is a no-op; a switched-away + /// row must not be resurrected. + private func resetLoadingStateAfterCancellation(for messageID: UUID) { + guard previewStates[messageID] == .loading else { return } + previewStates[messageID] = .idle + rebuildDisplayItem(for: messageID) + } + + /// A `.loading` state with no in-flight task in either fetch table is + /// orphaned: the task bailed on a path that never reset the state (a + /// dedup follower's `.loading` result, a cancellation between state write + /// and cleanup). Reset to `.idle` so the caller's fetch can re-fire; with + /// a task genuinely in flight this is a no-op. + private func recoverOrphanedLoadingState(for messageID: UUID) { + guard previewStates[messageID] == .loading, + previewFetchTasks[messageID] == nil, + imageFetchTasks[messageID] == nil else { return } + previewStates[messageID] = .idle + } + /// Cancel preview fetch for a message (called when cell scrolls away) func cancelPreviewFetch(for messageID: UUID) { previewFetchTasks[messageID]?.cancel() @@ -410,9 +476,17 @@ extension ChatViewModel { /// Request inline image fetch for a message (called when cell becomes visible) func requestImageFetch(for messageID: UUID) { guard envInputs.previewsEnabled else { return } - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + recoverOrphanedLoadingState(for: messageID) + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { + // Same self-heal as `requestPreviewFetch`: repair a desynced row + // rather than leaving it shimmering. + rebuildDisplayItem(for: messageID) + return + } guard let url = cachedURLs[messageID].flatMap(\.self), - ImageURLClassifier.isImageURL(url) else { return } + ImageURLClassifier.isImageURL(url) else { + return + } // Master on but auto-resolve off for this conversation type: park the // state at `.disabled` so the cell renders the tap-to-load placeholder and @@ -460,6 +534,7 @@ extension ChatViewModel { guard !Task.isCancelled else { imageFetchTasks.removeValue(forKey: messageID) + resetLoadingStateAfterCancellation(for: messageID) return } guard itemIndexByID[messageID] != nil else { @@ -489,6 +564,15 @@ extension ChatViewModel { } guard !Task.isCancelled, let entry else { imageFetchTasks.removeValue(forKey: messageID) + if Task.isCancelled { + resetLoadingStateAfterCancellation(for: messageID) + } else if previewStates[messageID] == .loading { + // Undecodable bytes are terminal: retrying would loop on the + // same cached data. Settle the row instead of stranding the + // shimmer. + previewStates[messageID] = .noPreview + rebuildDisplayItem(for: messageID) + } return } guard itemIndexByID[messageID] != nil else { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 9622c854..0ee69c54 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -84,11 +84,6 @@ final class ChatViewModel { // MARK: - Conversation Cache Storage - @ObservationIgnored var urlDetectionTask: Task? - /// Bumped on every buildItems rebuild. Only the URL-detection writer - /// checks this before mutating cachedURLs; single-row rebuilds via - /// rebuildDisplayItem do not write cachedURLs and do not need gating. - @ObservationIgnored var urlDetectionGeneration: UInt64 = 0 /// Tracks the last region scope sent to the device via setFloodScope. @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown @@ -118,6 +113,14 @@ final class ChatViewModel { /// `loadMessages` and on subsequent toggle changes. var envInputs: EnvInputs = .default + /// Memoized `MessageText.buildFormattedText` output keyed by message ID. + /// A message's text and direction are immutable, and every other + /// formatting input is a function of `envInputs`, so an entry stays valid + /// until `applyEnvInputs` changes the environment (which clears it). This + /// turns a pagination rebuild from O(timeline) attributed-string work into + /// O(new page); every already-loaded row is a cache hit. + @ObservationIgnored var formattedTextCache: [UUID: (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?)] = [:] + /// Update env-derived inputs and trigger a full rebuild when the value /// changes and there are messages to rebuild. Idempotent on no-change. func applyEnvInputs(_ new: EnvInputs) { @@ -130,6 +133,9 @@ final class ChatViewModel { MapSnapshotStore.shared.clearFailures() } envInputs = new + // The environment feeds every formatting input, so its cached output is + // now stale for all rows and must be rebuilt under the new appearance. + formattedTextCache.removeAll() guard !messages.isEmpty else { return } buildItems() } @@ -313,7 +319,9 @@ final class ChatViewModel { var dividerComputed = false /// Unread count above which the "New Messages" divider is shown (strictly greater). - private let newMessagesDividerThreshold = 10 + /// Zero shows the divider for any unread backlog; when the unreads fit on one + /// screen the open position clamps to the bottom with the divider line visible. + private let newMessagesDividerThreshold = 0 /// Computes the divider message ID from a fetched (unfiltered) message array. /// Must be called before filtering. Sets `dividerComputed = true`. @@ -427,9 +435,10 @@ final class ChatViewModel { /// nil while disconnected (offline browse never receives new messages). @ObservationIgnored var prefetcher: InlineImagePrefetcher? - /// Long-running subscription to `InlineImageDimensionsStore.resolutionStream`. + /// Long-running subscription to `InlineImageDimensionsStore.resolutionUpdates()`. /// On each emitted URL, every message whose body contains that URL is - /// rebuilt so the bubble picks up the now-known `cachedAspect`. + /// rebuilt so the bubble picks up the now-known `cachedAspect`. Interactive + /// view models only; prime view models never subscribe. @ObservationIgnored var dimensionResolutionTask: Task? /// Long-running subscription to `MapSnapshotStore.shared.resolutionStream`. @@ -441,6 +450,22 @@ final class ChatViewModel { /// it to bound their wall-clock budget. @ObservationIgnored var prefetchTimeout: Duration = ChatViewModel.defaultPrefetchTimeout + /// Write capability for the bound coordinator, minted by `bindWriter` in + /// `bindCoordinator`. Every timeline mutation goes through this; when a + /// newer view model binds the same coordinator (live open superseding a + /// prime, BFU scene rebuild), this writer goes stale and its writes no-op, + /// so a cold view model can never bake its state over the live timeline. + /// Reads keep using `coordinator` directly. `nil` when unbound or when a + /// `.prime` bind was denied because an interactive owner is active. + @ObservationIgnored var timelineWriter: ChatTimelineWriter? + + /// Role this view model binds coordinators with. `.interactive` for the + /// live conversation UI (default); `.prime` for the speculative warm paths + /// (navigation prefetch, arrival-time refresh), which also skips the + /// dimension/snapshot stream subscriptions because a prime has no + /// reactive role. + @ObservationIgnored private(set) var writerRole: ChatWriterRole = .interactive + /// Contact ID currently having its favorite status toggled (for loading UI) var togglingFavoriteID: UUID? @@ -464,8 +489,10 @@ final class ChatViewModel { onNavigateToMap: ((CLLocationCoordinate2D) -> Void)?, linkPreviewCache: (any LinkPreviewCaching)?, chatCoordinatorRegistry: ChatCoordinatorRegistry?, - conversation: ChatConversationType? + conversation: ChatConversationType?, + role: ChatWriterRole = .interactive ) { + writerRole = role dataStoreProvider = dependencies.dataStore messageServiceProvider = dependencies.messageService notificationServiceProvider = dependencies.notificationService @@ -494,29 +521,40 @@ final class ChatViewModel { bindCoordinator(registry: chatCoordinatorRegistry, conversation: conversation) } + /// Eagerly attaches the shared coordinator so a warm (prefetched or previously + /// opened) conversation renders its messages on the first frame, before the + /// load task runs. Sets only this view model's `coordinator` reference — never + /// the coordinator's rebuild hooks, which belong to the persistent view model + /// and are installed by `configure`/`bindCoordinator`. That omission is what + /// makes it safe to call from `init`, where transient view-model instances may + /// be created and discarded. + func attachCoordinator(_ coordinator: ChatCoordinator) { + self.coordinator = coordinator + } + private func bindCoordinator(registry: ChatCoordinatorRegistry?, conversation: ChatConversationType?) { guard let conversation else { return } guard let registry else { return } - let id: ChatConversationID = switch conversation { - case let .dm(contact): - .dm(radioID: contact.radioID, contactID: contact.id) - case let .channel(channel): - .channel(radioID: channel.radioID, channelIndex: channel.index) - } - let resolved = registry.coordinator(for: id) - // The most recently bound view model owns the per-ID rebuild hook - // the coordinator invokes after `applyReloadedIDs`. With two view - // models on the same conversation (iPad split view) the rendered - // state stays consistent because both observe the shared - // coordinator's `renderState` — only the per-view-model snapshot - // inputs (preview state, decoded images) come from the bound - // rebuilder. - resolved.renderItemRebuilder = { [weak self] messageID in - self?.rebuildDisplayItem(for: messageID) - } - resolved.renderStateInvalidated = { [weak self] in - self?.handleRenderStateInvalidated() - } + let resolved = registry.coordinator(for: conversation.coordinatorID) + // The most recently bound view model owns both the rebuild hooks and + // the write capability; `bindWriter` installs them as one atomic act. + // An `.interactive` bind always succeeds and revokes any prior writer + // (a stale prime's in-flight bakes then no-op at the coordinator); a + // `.prime` bind is denied while a live interactive owner exists, in + // which case `timelineWriter` stays nil and this view model's writes + // all no-op. With two view models on the same conversation the + // rendered state stays consistent because both observe the shared + // coordinator's `renderState`; only the current writer bakes items. + timelineWriter = resolved.bindWriter( + owner: self, + role: writerRole, + renderItemRebuilder: { [weak self] messageID in + self?.rebuildDisplayItem(for: messageID) + }, + renderStateInvalidated: { [weak self] in + self?.handleRenderStateInvalidated() + } + ) coordinator = resolved } @@ -524,6 +562,26 @@ final class ChatViewModel { buildItems() } + #if DEBUG + /// Test seam mirroring `bindCoordinator` for suites that construct a + /// coordinator directly instead of resolving one through a registry: + /// installs the interactive writer and the rebuild hooks in one act, + /// exactly like a live open. + func bindCoordinatorForTesting(_ coordinator: ChatCoordinator) { + timelineWriter = coordinator.bindWriter( + owner: self, + role: .interactive, + renderItemRebuilder: { [weak self] messageID in + self?.rebuildDisplayItem(for: messageID) + }, + renderStateInvalidated: { [weak self] in + self?.handleRenderStateInvalidated() + } + ) + self.coordinator = coordinator + } + #endif + /// Build the receive-time prefetcher and start (or restart) the /// dimension-resolution subscription. Called from `configure(...)` when a /// `linkPreviewCache` is supplied. The per-connection inputs are nil while @@ -533,6 +591,29 @@ final class ChatViewModel { dimensionsStore: InlineImageDimensionsStore?, prefetchDataStore: (any PersistenceStoreProtocol)? ) { + // Prime view models have no reactive role: they warm caches with + // awaited prefetches and bake once; stream-driven rebuilds belong to + // the interactive view model only. Subscribing a prime would fan + // resolution events into a cold state dictionary and bake shimmer + // items over the live timeline. + guard writerRole == .interactive else { + snapshotResolutionTask?.cancel() + snapshotResolutionTask = nil + dimensionResolutionTask?.cancel() + dimensionResolutionTask = nil + guard let dimensionsStore, let prefetchDataStore else { + prefetcher = nil + return + } + prefetcher = InlineImagePrefetcher( + imageCache: InlineImageCache.shared, + linkPreviewCache: linkPreviewCache, + dimensionsStore: dimensionsStore, + dataStore: prefetchDataStore + ) + return + } + // The snapshot-resolution stream is a process-lifetime singleton with no // dependency on per-connection services, so subscribe once regardless of // connection state: offline browse of cached coordinate messages still @@ -564,7 +645,7 @@ final class ChatViewModel { private func startObservingDimensionResolutions(store: InlineImageDimensionsStore) { dimensionResolutionTask?.cancel() dimensionResolutionTask = Task { [weak self] in - for await resolvedURL in store.resolutionStream { + for await resolvedURL in store.resolutionUpdates() { guard !Task.isCancelled else { return } await self?.handleDimensionResolution(resolvedURL) } diff --git a/MC1/Views/Components/NavigationHeaderModifier.swift b/MC1/Views/Components/NavigationHeaderModifier.swift index 483de076..8084a4bb 100644 --- a/MC1/Views/Components/NavigationHeaderModifier.swift +++ b/MC1/Views/Components/NavigationHeaderModifier.swift @@ -12,19 +12,48 @@ struct NavigationHeaderModifier: ViewModifier { let title: String let subtitle: String let subtitleAccessibilityLabel: String? + /// iOS 26 only: render the title/subtitle inside a Liquid Glass capsule as a principal toolbar + /// item, so the name stays legible above content that now scrolls edge-to-edge behind the bar. + let glassTitleCapsule: Bool + /// iOS 26 capsule only: optional leading avatar and a tap action for the whole capsule + /// (e.g. opening the conversation's info sheet). + let titleIcon: AnyView? + let onTitleTap: (() -> Void)? @State private var showHeader = false func body(content: Content) -> some View { #if os(iOS) if #available(iOS 26, *) { - // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() - // renders in system chrome with no public API to override its accessibility label. - // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. - content - .navigationTitle(title) - .navigationSubtitle(subtitle) - .navigationBarTitleDisplayMode(.inline) + if glassTitleCapsule { + content + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + // Self-contained fade: a toolbar item is hosted inside UIKit's nav bar, so an + // animation driven from the modifier's state snaps instead of animating. Running + // the fade from the item's own @State/onAppear keeps it in the hosted view's + // rendering context, where it actually animates. + GlassCapsuleTitle( + title: title, + subtitle: subtitle, + subtitleAccessibilityLabel: subtitleAccessibilityLabel, + minimumScaleFactor: Self.legacySubtitleMinimumScaleFactor, + icon: titleIcon, + onTap: onTitleTap + ) + } + } + } else { + // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() + // renders in system chrome with no public API to override its accessibility label. + // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. + content + .navigationTitle(title) + .navigationSubtitle(subtitle) + .navigationBarTitleDisplayMode(.inline) + } } else { legacyHeader(content: content) } @@ -42,18 +71,14 @@ struct NavigationHeaderModifier: ViewModifier { .toolbar { if showHeader { ToolbarItem(placement: .principal) { - VStack(spacing: 0) { - Text(title) - .font(.headline) - - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(Self.legacySubtitleMinimumScaleFactor) - .truncationMode(.tail) - .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) - } + HeaderTitleLabel( + title: title, + subtitle: subtitle, + subtitleAccessibilityLabel: subtitleAccessibilityLabel, + minimumScaleFactor: Self.legacySubtitleMinimumScaleFactor, + icon: titleIcon, + onTap: onTitleTap + ) } } } @@ -66,10 +91,99 @@ struct NavigationHeaderModifier: ViewModifier { } } +/// Shared title/subtitle label with an optional leading avatar, used by both the iOS 26 glass +/// capsule and the legacy principal toolbar item. Becomes a tap target when `onTap` is set. +private struct HeaderTitleLabel: View { + let title: String + let subtitle: String + let subtitleAccessibilityLabel: String? + let minimumScaleFactor: CGFloat + let icon: AnyView? + let onTap: (() -> Void)? + + var body: some View { + if let onTap { + Button(action: onTap) { content } + .buttonStyle(.plain) + .contentShape(.capsule) + } else { + content + } + } + + private var content: some View { + HStack(spacing: 10) { + icon + + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.headline) + + if !subtitle.isEmpty { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(minimumScaleFactor) + .truncationMode(.tail) + .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) + } + } + } + .padding(.leading, icon == nil ? 14 : 6) + .padding(.trailing, 14) + .padding(.vertical, 5) + } +} + +/// iOS 26 Liquid Glass title capsule for the principal toolbar slot. Fades itself in on appear +/// so it doesn't pop in after the chat's first-load layout settles. +@available(iOS 26.0, *) +private struct GlassCapsuleTitle: View { + let title: String + let subtitle: String + let subtitleAccessibilityLabel: String? + let minimumScaleFactor: CGFloat + let icon: AnyView? + let onTap: (() -> Void)? + + @State private var visible = false + + var body: some View { + HeaderTitleLabel( + title: title, + subtitle: subtitle, + subtitleAccessibilityLabel: subtitleAccessibilityLabel, + minimumScaleFactor: minimumScaleFactor, + icon: icon, + onTap: onTap + ) + .glassEffect(.regular, in: .capsule) + .opacity(visible ? 1 : 0) + .onAppear { + withAnimation(.easeIn(duration: 0.25)) { visible = true } + } + } +} + extension View { /// Applies an animated navigation header with title and subtitle. /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. - func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { - modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) + func navigationHeader( + title: String, + subtitle: String, + subtitleAccessibilityLabel: String? = nil, + glassTitleCapsule: Bool = false, + titleIcon: AnyView? = nil, + onTitleTap: (() -> Void)? = nil + ) -> some View { + modifier(NavigationHeaderModifier( + title: title, + subtitle: subtitle, + subtitleAccessibilityLabel: subtitleAccessibilityLabel, + glassTitleCapsule: glassTitleCapsule, + titleIcon: titleIcon, + onTitleTap: onTitleTap + )) } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index d47f6488..b70f19c4 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -35,20 +35,31 @@ struct RoomConversationView: View { shouldSuppressOpen: { selectedRoomMessage != nil } ) .safeAreaInset(edge: .bottom, spacing: 0) { - if !session.isConnected { - makeDisconnectedBanner() - } else if session.canPost { - makeInputBar() - } else { - makeReadOnlyBanner() + Group { + if !session.isConnected { + makeDisconnectedBanner() + } else if session.canPost { + makeInputBar() + } else { + makeReadOnlyBanner() + } } + .chatComposeBarFade(canvas: theme.surfaces?.canvas ?? Color(.systemBackground)) } .animation(.default, value: session.isConnected) - .navigationHeader(title: session.name, subtitle: connectionStatus) + .navigationHeader( + title: session.name, + subtitle: connectionStatus, + glassTitleCapsule: true, + titleIcon: AnyView(NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 30)), + onTitleTap: { showingRoomInfo = true } + ) .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { - showingRoomInfo = true + if #unavailable(iOS 26) { + ToolbarItem(placement: .primaryAction) { + Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { + showingRoomInfo = true + } } } } @@ -196,7 +207,7 @@ struct RoomConversationView: View { messages: viewModel.messages, isAtBottom: $isAtBottom, unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, + scrollToBottomRequest: scrollToBottomRequest, session: session, theme: theme, onRetry: { id in @@ -296,15 +307,12 @@ private struct MessagesView: View { let messages: [RoomMessageDTO] @Binding var isAtBottom: Bool @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int + let scrollToBottomRequest: Int let session: RemoteNodeSessionDTO let theme: Theme let onRetry: (UUID) -> Void let onLongPress: (RoomMessageDTO) -> Void - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.openURL) private var openURL var body: some View { @@ -316,7 +324,7 @@ private struct MessagesView: View { EmptyMessagesView(session: session) } else { let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) - ChatTableView( + ChatTiledView( items: messages, cellContent: { message in messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) @@ -324,30 +332,10 @@ private struct MessagesView: View { .environment(\.openURL, openURL) }, contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), isAtBottom: $isAtBottom, unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: .constant(0), - offscreenMentionIDs: .constant([]), - onSecondaryClick: onLongPress, - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false) + scrollToBottomRequest: scrollToBottomRequest ) - .overlay(alignment: .bottomTrailing) { - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - .padding(.trailing, 16) - .padding(.bottom, 8) - } } } .themedCanvas(theme) diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 5b4d59aa..aec2ea3b 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -107,7 +107,7 @@ final class RoomConversationViewModel { } /// Optimistically append a message if not already present. - /// Called synchronously before async reload to ensure ChatTableView + /// Called synchronously before async reload so the message list /// sees the new count immediately for unread tracking. func appendMessageIfNew(_ message: RoomMessageDTO) { guard !messages.contains(where: { $0.id == message.id }) else { return } @@ -156,7 +156,7 @@ final class RoomConversationViewModel { switch event { case let .roomMessageReceived(message, sessionID): - // Optimistic append first so the ChatTableView sees the new count + // Optimistic append first so the message list sees the new count // immediately for unread tracking, then coalesce the reload so a // burst of incoming room messages triggers one DB sync, not N. guard sessionID == session.id else { return } diff --git a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift index 9d7837ac..bc4ae9fc 100644 --- a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift @@ -4,14 +4,12 @@ import SwiftUI /// Chat settings section for incoming message routing info display struct MessagesSettingsSection: View { @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false var body: some View { Section { - Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift index f76facc2..04fa238d 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift @@ -23,6 +23,13 @@ public struct ChatRenderState: Sendable, Equatable { public let totalFetchedCount: Int public let phase: LoadPhase + /// Message ID of the first item baked with the "New Messages" divider, or nil + /// when no item carries it. Derived from the baked grouping flags so a warm + /// coordinator exposes the open-at-divider target on the first frame. + public var newMessagesDividerItemID: UUID? { + items.first { $0.grouping.showNewMessagesDivider }?.id + } + public init( items: [MessageItem], itemIndexByID: [UUID: Int], diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift index 2ce0e2f1..fb6de5a2 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift @@ -12,8 +12,15 @@ public struct LinkPreviewFragmentState: Sendable, Hashable { public let mode: Mode - public init(mode: Mode) { + /// Remembered width-over-height ratio of the hero image for this URL, from + /// the persisted dimensions store. Lets the loading shimmer reserve the + /// final card footprint instead of guessing `fallbackAspect`, so the cell + /// keeps one height across the load. `nil` when the size was never seen. + public let heroAspectHint: Double? + + public init(mode: Mode, heroAspectHint: Double? = nil) { self.mode = mode + self.heroAspectHint = heroAspectHint } /// The single openable URL the preview resolves to: the destination of a diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index 3be9fc11..47bae524 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -25,6 +25,12 @@ public struct MessageBuildInputs: Sendable, Hashable { /// correct frame on first paint. `nil` for non-image URLs or when the /// dimensions store has not yet seen this URL. public let inlineImageAspect: Double? + /// Remembered width-over-height ratio of the link-preview hero image for + /// `cachedURL`, resolved at build time from the persisted dimensions store. + /// Lets the loading shimmer reserve the final card footprint so the cell + /// does not change height when the image arrives. `nil` when the URL's + /// hero size has never been seen. + public let previewHeroAspect: Double? /// Latitude of the first linkified coordinate in the message text, or nil. /// Drives the `.mapPreview` fragment. Stored as `Double` for `Hashable`. public let mapPreviewLatitude: Double? @@ -55,6 +61,7 @@ public struct MessageBuildInputs: Sendable, Hashable { hasPreviewIconRef: Bool, imageIsGIF: Bool, inlineImageAspect: Double? = nil, + previewHeroAspect: Double? = nil, mapPreviewLatitude: Double? = nil, mapPreviewLongitude: Double? = nil, isMapPreviewReady: Bool = false, @@ -78,6 +85,7 @@ public struct MessageBuildInputs: Sendable, Hashable { self.hasPreviewIconRef = hasPreviewIconRef self.imageIsGIF = imageIsGIF self.inlineImageAspect = inlineImageAspect + self.previewHeroAspect = previewHeroAspect self.mapPreviewLatitude = mapPreviewLatitude self.mapPreviewLongitude = mapPreviewLongitude self.isMapPreviewReady = isMapPreviewReady diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift index bcb6ac7e..64daeaad 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift @@ -1,6 +1,9 @@ import Foundation -public extension ChatCoordinator { +/// Mutation methods are internal: app code mutates a timeline only through +/// a `ChatTimelineWriter` minted by `bindWriter(owner:role:...)`, which +/// closes the stale-writer clobber class at the module boundary. +extension ChatCoordinator { /// Replace the entire canonical messages list. Rebuilds the lookup /// dictionary and bumps `renderStateID` so any in-flight off-main /// build discards on apply. Settles `renderState.phase` to `.loaded`, diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift index 5ff6562f..b186f6ab 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift @@ -1,6 +1,9 @@ import Foundation -public extension ChatCoordinator { +/// Internal: reached from app code only via `ChatTimelineWriter.rebuildItems`, +/// because scheduling a bake is a write (last-scheduled-wins) and must be +/// owner-gated. +extension ChatCoordinator { /// Rebuild `renderState` from a snapshot of inputs already assembled on /// the main actor. Runs the per-message builder loop off the main actor /// inside a `Task { @concurrent }` hop, then applies the result on main diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift index 3fb24be0..5d4e0617 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift @@ -1,6 +1,9 @@ import Foundation -public extension ChatCoordinator { +/// Internal: reload scheduling is reached from app code only via +/// `ChatTimelineWriter.enqueueReload`; `hardReset`/`cancelInFlight` are +/// coordinator/registry internals. +extension ChatCoordinator { /// Single chokepoint for ack / retry / fail / heard-repeat / reaction /// events. Unions IDs into `pendingReloadIDs` and schedules a coalesced /// load if one is not already in flight. The load takes an atomic diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index 3dfe25a3..f62f49f7 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -1,6 +1,18 @@ import Foundation import OSLog +/// Role a bound timeline writer plays. +/// +/// `.interactive` is the live conversation UI: binding it always succeeds +/// and revokes any prior writer. `.prime` is a speculative warm +/// (navigation-time prefetch, arrival-time refresh): binding succeeds only +/// while no live interactive owner exists, so a prime can populate an idle +/// coordinator but can never write over an open chat. +public enum ChatWriterRole: String, Sendable { + case interactive + case prime +} + /// Per-(radio, conversation) source of truth for chat timeline state. /// /// Replaces the parallel-storage model on `ChatViewModel`. Two @@ -19,6 +31,19 @@ public final class ChatCoordinator { /// initial-load sizing so post-reset renders match the normal load. public static let pageSize: Int = 50 + /// Read messages loaded above the first unread so the "New Messages" divider + /// has a little context to sit beneath rather than pinning to the very top. + public static let dividerReadContext: Int = 12 + + /// Initial fetch size for opening a conversation. Guarantees every unread + /// message (plus a little read context) lands in the first page: otherwise a + /// conversation with more than `pageSize` unread would place the divider on a + /// message that only pages in later, leaving the jump-to-divider button with no + /// materialized target to scroll to. + public static func initialPageSize(unreadCount: Int) -> Int { + max(pageSize, unreadCount + dividerReadContext) + } + public let conversationID: ChatConversationID @ObservationIgnored @@ -114,18 +139,70 @@ public final class ChatCoordinator { /// (preview state, cached URLs, decoded images). Stays `nil` when no /// view model is bound — `applyReloadedIDs` then refreshes DTOs without /// rebuilding render items, which matches headless and test usage. + /// Installed only through `bindWriter(owner:role:...)` so hook ownership + /// and write ownership are one atomic act. /// `@ObservationIgnored` because no view body reads this closure. @ObservationIgnored - public var renderItemRebuilder: (@MainActor (UUID) -> Void)? + public internal(set) var renderItemRebuilder: (@MainActor (UUID) -> Void)? /// Fires when the coordinator's `renderState.items` no longer reflects /// canonical `messages` and the bound view model must reassemble per-message /// inputs (preview state, cached URLs, decoded images live on main and are /// owned by the VM) before `rebuildItems` can be called again. Triggered /// when a fresher mutation lands mid-flight and `setRenderState` rejects - /// the stale rebuild, and after `hardReset`'s `replaceAll`. + /// the stale rebuild, and after `hardReset`'s `replaceAll`. Installed only + /// through `bindWriter(owner:role:...)`. @ObservationIgnored - public var renderStateInvalidated: (@MainActor () -> Void)? + public internal(set) var renderStateInvalidated: (@MainActor () -> Void)? + + // MARK: - Writer ownership + + /// Generation stamp of the most recent `bindWriter` call. Every + /// `ChatTimelineWriter` carries the generation it was minted with; a + /// writer whose generation no longer matches is stale and its mutations + /// no-op. `@ObservationIgnored`: never read from a view body. + @ObservationIgnored + private(set) var writerGeneration: UInt64 = 0 + + /// The object (view model) holding the current writer, kept weak so a + /// deallocated owner frees the slot for the next `.prime` bind without + /// any explicit unbind call. + @ObservationIgnored + private(set) weak var writerOwner: AnyObject? + + /// Role of the current writer. Meaningful only while `writerOwner` is + /// non-nil; a deallocated owner leaves a stale role behind, which + /// `bindWriter` treats as vacant. + @ObservationIgnored + private(set) var writerRole: ChatWriterRole = .prime + + /// Claims write access to this timeline and installs the rebuild hooks. + /// + /// `.interactive` always succeeds, bumping the generation so every + /// previously minted writer goes stale. `.prime` succeeds only while no + /// live interactive owner exists (vacant slot, deallocated owner, or a + /// prior prime), and returns nil otherwise: a speculative warm must + /// never write over an open conversation. + /// + /// Reads (`messages`, `messagesByID`, `renderState`) stay unrestricted: + /// a superseded view model can still render the shared state. + public func bindWriter( + owner: AnyObject, + role: ChatWriterRole, + renderItemRebuilder: (@MainActor (UUID) -> Void)? = nil, + renderStateInvalidated: (@MainActor () -> Void)? = nil + ) -> ChatTimelineWriter? { + if role == .prime, writerRole == .interactive, writerOwner != nil { + logger.info("bindWriter: prime bind denied; interactive owner active for \(String(describing: self.conversationID), privacy: .public)") + return nil + } + writerGeneration &+= 1 + writerOwner = owner + writerRole = role + self.renderItemRebuilder = renderItemRebuilder + self.renderStateInvalidated = renderStateInvalidated + return ChatTimelineWriter(coordinator: self, generation: writerGeneration, role: role) + } init( conversationID: ChatConversationID, @@ -147,5 +224,18 @@ public final class ChatCoordinator { let store = PersistenceStore(modelContainer: container) return ChatCoordinator(conversationID: conversationID, dataStore: store) } + + /// Test-only fixture seam: seeds the timeline without minting a writer, + /// so fixture setup neither steals the bound view model's hooks nor + /// revokes its write capability. Release code mutates timelines only + /// through `ChatTimelineWriter`. + public func replaceAllForTesting(_ newMessages: [MessageDTO]) { + replaceAll(newMessages) + } + + /// Test-only fixture seam; see `replaceAllForTesting`. + public func markLoadedForTesting() { + markLoaded() + } #endif } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift index b86281df..92e640d9 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift @@ -50,6 +50,14 @@ public final class ChatCoordinatorRegistry { return coordinator } + /// Returns the coordinator already tracked for `id`, or nil if none exists. + /// A pure lookup: it neither creates an entry nor promotes LRU order, so the + /// navigation-time prefetch can check whether a conversation is already warm + /// without polluting the cache. + public func existingCoordinator(for id: ChatConversationID) -> ChatCoordinator? { + entries.first(where: { $0.id == id })?.coordinator + } + public func rebind(dataStore: PersistenceStore) { tearDown() self.dataStore = dataStore diff --git a/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift new file mode 100644 index 00000000..58b6aa9b --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Generation-stamped write capability for a `ChatCoordinator` timeline. +/// +/// Minted exclusively by `ChatCoordinator.bindWriter(owner:role:...)`; the +/// coordinator's mutation methods are internal to MC1Services, so holding a +/// writer is the only way app code can mutate a timeline. Every forwarder +/// checks the mint generation against the coordinator's current one: once a +/// newer writer is bound, this writer's mutations no-op, so a stale prime +/// (or a superseded view model) can never write over the live conversation. +/// +/// Reads are not gated; consumers keep reading `coordinator.renderState`, +/// `messages`, and `messagesByID` directly. +@MainActor +public final class ChatTimelineWriter { + private let coordinator: ChatCoordinator + private let generation: UInt64 + + /// Role this writer was bound with. Diagnostic only; staleness is + /// decided purely by generation. + public let role: ChatWriterRole + + init(coordinator: ChatCoordinator, generation: UInt64, role: ChatWriterRole) { + self.coordinator = coordinator + self.generation = generation + self.role = role + } + + /// Whether this writer still holds write access. + public var isCurrent: Bool { + generation == coordinator.writerGeneration + } + + /// Runs `body` only while this writer is current. A dropped `.prime` + /// write is expected teardown noise; a dropped `.interactive` write + /// almost always means a missed rebind (BFU scene rebuild, role + /// misassignment) and would present as quietly frozen bubbles, so it + /// logs at fault level in DEBUG builds. + private func ifCurrent(_ operation: String, _ body: () -> Void) { + guard isCurrent else { + #if DEBUG + if role == .interactive { + coordinator.logger.fault("stale interactive writer dropped \(operation, privacy: .public); missed rebind?") + } else { + coordinator.logger.info("stale \(self.role.rawValue, privacy: .public) writer dropped \(operation, privacy: .public)") + } + #else + coordinator.logger.info("stale \(self.role.rawValue, privacy: .public) writer dropped \(operation, privacy: .public)") + #endif + return + } + body() + } + + // MARK: - Message-list mutations + + public func replaceAll(_ newMessages: [MessageDTO]) { + ifCurrent("replaceAll") { coordinator.replaceAll(newMessages) } + } + + public func beginLoading() { + ifCurrent("beginLoading") { coordinator.beginLoading() } + } + + public func markLoaded() { + ifCurrent("markLoaded") { coordinator.markLoaded() } + } + + public func prepend(_ older: [MessageDTO]) { + ifCurrent("prepend") { coordinator.prepend(older) } + } + + /// Returns `false` when the message was already present, or when this + /// writer is stale and nothing was appended. + @discardableResult + public func append(_ message: MessageDTO) -> Bool { + var appended = false + ifCurrent("append") { appended = coordinator.append(message) } + return appended + } + + public func update(messageID: UUID, _ transform: (inout MessageDTO) -> Void) { + ifCurrent("update") { coordinator.update(messageID: messageID, transform) } + } + + public func remove(messageID: UUID) { + ifCurrent("remove") { coordinator.remove(messageID: messageID) } + } + + public func replaceMessagesPreservingByID(_ reordered: [MessageDTO]) { + ifCurrent("replaceMessagesPreservingByID") { coordinator.replaceMessagesPreservingByID(reordered) } + } + + // MARK: - Render-state mutations + + public func updateRenderState(_ transform: (ChatRenderState) -> ChatRenderState) { + ifCurrent("updateRenderState") { coordinator.updateRenderState(transform) } + } + + public func appendRenderItem(_ item: MessageItem) { + ifCurrent("appendRenderItem") { coordinator.appendRenderItem(item) } + } + + public func updateRenderItem(id: UUID, _ transform: (MessageItem) -> MessageItem) { + ifCurrent("updateRenderItem") { coordinator.updateRenderItem(id: id, transform) } + } + + public func removeRenderItem(id: UUID) { + ifCurrent("removeRenderItem") { coordinator.removeRenderItem(id: id) } + } + + public func applyStatusUpdate( + messageID: UUID, + status: MessageStatus, + roundTripTime: UInt32? = nil, + userInitiated: Bool = false + ) { + ifCurrent("applyStatusUpdate") { + coordinator.applyStatusUpdate( + messageID: messageID, + status: status, + roundTripTime: roundTripTime, + userInitiated: userInitiated + ) + } + } + + // MARK: - Rebuild / reload scheduling + + /// Scheduling a full bake is a write: `rebuildItems` bumps the + /// generation counter before capturing it, so the last-scheduled build + /// always wins. That is exactly why a stale writer must not be able to + /// schedule one. + public func rebuildItems( + inputs: [(MessageDTO, MessageBuildInputs)], + envInputs: EnvInputs, + postApply: (@MainActor () -> Void)? = nil + ) { + ifCurrent("rebuildItems") { + coordinator.rebuildItems(inputs: inputs, envInputs: envInputs, postApply: postApply) + } + } + + public func enqueueReload(updatedMessageIDs: Set) { + ifCurrent("enqueueReload") { coordinator.enqueueReload(updatedMessageIDs: updatedMessageIDs) } + } + + public func enqueueReload(messageID: UUID) { + ifCurrent("enqueueReload") { coordinator.enqueueReload(messageID: messageID) } + } +} diff --git a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift index 183b82b7..9cf140fc 100644 --- a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift +++ b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift @@ -26,8 +26,11 @@ public actor InlineImageDimensionsStore { private let fileURL: URL private var entries: [String: Entry] = [:] private let aspectMirror: OSAllocatedUnfairLock<[String: Double]> - private let streamContinuation: AsyncStream.Continuation - private let stream: AsyncStream + + /// Multicast source for resolution events: every subscriber receives + /// every saved URL, so coexisting consumers (iPad split view) never + /// steal each other's events. + private let resolutionBroadcaster = EventBroadcaster() /// Production initializer using the default Application Support path. public init() { @@ -40,13 +43,6 @@ public actor InlineImageDimensionsStore { let resolvedURL = fileURL ?? Self.defaultFileURL() self.fileURL = resolvedURL - let (stream, continuation) = AsyncStream.makeStream( - of: URL.self, - bufferingPolicy: .bufferingOldest(Self.resolutionStreamBufferDepth) - ) - self.stream = stream - streamContinuation = continuation - let directory = resolvedURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -79,7 +75,7 @@ public actor InlineImageDimensionsStore { entries[key] = entry aspectMirror.withLock { $0[key] = aspect } - streamContinuation.yield(url) + resolutionBroadcaster.yield(url) persist() } @@ -89,16 +85,20 @@ public actor InlineImageDimensionsStore { aspectMirror.withLock { $0[url.absoluteString] } } - /// Broadcast stream of URLs whose aspect was just (re)saved. Emits on every - /// save call, including idempotent re-saves where the aspect did not change. - /// - /// Single-consumer by design; `ChatViewModel` owns this in non-split-view - /// contexts. In iPad split view (multiple `ChatViewModel`s alive at once), - /// events are delivered to whichever subscriber happens to be iterating - /// first; affected bubbles still rebuild via other triggers (visible-cell - /// reload, retry, manual `rebuildDisplayItem`). - public nonisolated var resolutionStream: AsyncStream { - stream + /// Returns a fresh multicast stream of URLs whose aspect was just + /// (re)saved. Emits on every save call, including idempotent re-saves + /// where the aspect did not change. Every subscriber receives every + /// event yielded after it subscribes, so two live view models (iPad + /// split view) never split the event stream between them. Per-subscriber + /// buffering keeps the newest events when a consumer stalls. + public nonisolated func resolutionUpdates() -> AsyncStream { + resolutionBroadcaster.subscribe( + bufferingPolicy: .bufferingNewest(Self.resolutionStreamBufferDepth) + ) + } + + deinit { + resolutionBroadcaster.finish() } private func persist() { diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index cfaa38f6..083766f6 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -180,7 +180,7 @@ public enum MessageFragmentBuilder { case .malwareWarning: .idle } - return LinkPreviewFragmentState(mode: mode) + return LinkPreviewFragmentState(mode: mode, heroAspectHint: inputs.previewHeroAspect) } private static func makeEnvelope( @@ -207,20 +207,20 @@ public enum MessageFragmentBuilder { inputs: MessageBuildInputs, envInputs: EnvInputs ) -> MessageFooter { - let showHop = envInputs.showIncomingHopCount && message.isFloodRouted + let showHop = envInputs.showIncomingHopCount && message.isFloodRouted && !message.isOutgoing let region: String? = if envInputs.showIncomingRegion, message.isFloodRouted { message.regionScope } else { nil } - // Send time shows on every incoming message (DM and channel) — unlike hop - // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected - // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a - // misleading timestamp in the bubble; the badge flags the substitution and the - // raw value is available in the message info sheet. - let sendTimeToShow: Date? = - (envInputs.showIncomingSendTime && !message.isOutgoing) - ? message.senderDate : nil + // Send time shows inside every bubble — incoming and outgoing, DM and + // channel. It is the sole time surface now that the centered cluster + // marker is gone, so it is unconditional (no toggle, not gated on + // `isFloodRouted`). Shows the clock-corrected `senderDate`, not the raw + // wire value, so a skewed sender clock doesn't put a misleading timestamp + // in the bubble; the badge flags the substitution and the raw value is + // available in the message info sheet. + let sendTimeToShow: Date? = message.senderDate return MessageFooter( showHop: showHop, hopCount: message.hopCount, diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index ba58c229..402dfeb0 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -10,10 +10,79 @@ extension MockDataProvider { case publicChannelIndex: return publicChannelMessages(now: now) case bayAreaChannelIndex: return bayAreaChannelMessages(now: now) case trailCrewChannelIndex: return trailCrewChannelMessages(now: now) + case meshHQChannelIndex: return meshHQChannelMessages(now: now) default: return [] } } + /// Mesh HQ: a long, multi-sender backlog. Its unread count exceeds one page + /// (pageSize is 50), so the first-unread message — where the "New Messages" + /// divider belongs — only loads when the initial page is sized to cover all + /// unread. Exercises the jump-to-divider scroll and all-unread-in-one-page load. + static let meshHQTotalMessages = 80 + static let meshHQUnreadCount = 60 + + private static func meshHQChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 2) + let senders: [(name: String, seed: UInt8)] = [ + ("Alice Chen", 10), ("Bob Martinez", 20), ("Carol Diaz", 90), + ("Frank Wilson", 60), ("Hannah Lee", 80) + ] + let lines = [ + "Morning net check — who's on frequency?", + "Copy, strong signal from the east ridge.", + "Repeater 3 is back online after the firmware push.", + "Battery bank held through the night, 82% remaining.", + "Anyone have eyes on the weather coming over the pass?", + "Rain expected this afternoon, plan accordingly.", + "Trace route to the summit node looks clean, 3 hops.", + "Lost the link to node 7 for a bit, back now.", + "New antenna mount is up, gaining about 4 dB.", + "Field team checking in from the trailhead.", + "Packet loss down to under 2% since the reroute.", + "Reminder: monthly maintenance window is Sunday.", + "Great turnout on the group trace test today." + ] + + return (0.. UUID { + UUID(uuidString: "C3000000-0000-0000-0000-\(String(format: "%012X", i))")! + } + /// Public: multi-sender chatter with a same-sender cluster, plus our own reply. private static func publicChannelMessages(now: Date) -> [MessageDTO] { let path = encodePathLen(hashSize: 1, hopCount: 2) diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift index 52ec340b..30d4b9d1 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift @@ -43,6 +43,22 @@ extension MockDataProvider { unreadCount: 0, notificationLevel: .muted, isFavorite: false + ), + // Long backlog whose unread count exceeds one page (pageSize is 50), so the + // first-unread message — where the "New Messages" divider belongs — only + // exists once the initial load is sized to cover all unread. Exercises both + // the jump-to-divider scroll and the all-unread-in-one-page load sizing. + ChannelDTO( + id: meshHQChannelID, + radioID: simulatorDeviceID, + index: meshHQChannelIndex, + name: "Mesh HQ", + secret: channelSecret(seed: 0xD0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-90), + unreadCount: meshHQUnreadCount, + notificationLevel: .all, + isFavorite: false ) ] } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift index 83728a37..0e08a9bb 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift @@ -129,6 +129,19 @@ extension MockDataProvider { pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), senderKeyPrefix: key, isRead: false + ), + // 25-node path with 3-byte hop IDs to inspect the path chip layout with a long path. + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-0000000000FF")!, + createdAt: now.addingTimeInterval(-600), + text: "Relayed all the way across the mesh to reach you", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 3, hopCount: 25), + snr: 3.2, + pathNodes: Data((0..<25).flatMap { [UInt8(0x10 + $0), 0xA3, 0xB7] }), + senderKeyPrefix: key, + isRead: false ) ] } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift index 4d42a89f..e4bcf0aa 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift @@ -24,11 +24,13 @@ public enum MockDataProvider { public static let publicChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000000")! public static let bayAreaChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000001")! public static let trailCrewChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000002")! + public static let meshHQChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000003")! /// Channel slot indices (mirror firmware slot positions) public static let publicChannelIndex: UInt8 = 0 public static let bayAreaChannelIndex: UInt8 = 1 public static let trailCrewChannelIndex: UInt8 = 2 + public static let meshHQChannelIndex: UInt8 = 3 /// Message IDs referenced by more than one seed helper (reaction / repeat / /// link-preview targets) so the builders and the post-save mutators agree. diff --git a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift index 25f01bef..24bc7efa 100644 --- a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift +++ b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift @@ -29,8 +29,15 @@ final class EventBroadcaster: Sendable { /// Returns a stream receiving every event yielded after this call. /// If `finish()` has already been called, returns a stream that finishes /// immediately. Cancelling the consuming task unregisters the subscriber. - func subscribe() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Event.self) + /// + /// `bufferingPolicy` bounds the per-subscriber buffer. Latest-state + /// notification streams should pass `.bufferingNewest(n)` so a stalled + /// consumer accumulates the most recent events instead of an unbounded + /// backlog. + func subscribe( + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded + ) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Event.self, bufferingPolicy: bufferingPolicy) let alreadyFinished = state.withLock { locked -> Bool in guard !locked.isFinished else { return true } let id = UUID() diff --git a/MC1Services/Tests/MC1ServicesTests/ChatTimelineWriterTests.swift b/MC1Services/Tests/MC1ServicesTests/ChatTimelineWriterTests.swift new file mode 100644 index 00000000..d2a156e6 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/ChatTimelineWriterTests.swift @@ -0,0 +1,192 @@ +import Foundation +@testable import MC1Services +import Testing + +/// Owner stand-in for `bindWriter`; the coordinator holds it weakly, so +/// tests can drop it to simulate a deallocated view model. +@MainActor +private final class WriterOwner {} + +@Suite("ChatTimelineWriter Tests") +@MainActor +struct ChatTimelineWriterTests { + private func makeMessage(text: String = "hello") -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: text, + timestamp: 1, + createdAt: Date(), + sortDate: nil, + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `interactive bind always succeeds and revokes the prior writer`() throws { + let coordinator = ChatCoordinator.makeForTesting() + let ownerA = WriterOwner() + let ownerB = WriterOwner() + + let writerA = try #require(coordinator.bindWriter(owner: ownerA, role: .interactive)) + #expect(writerA.isCurrent) + + let writerB = try #require(coordinator.bindWriter(owner: ownerB, role: .interactive)) + #expect(writerB.isCurrent) + #expect(!writerA.isCurrent) + } + + @Test + func `stale writer mutations all no-op`() throws { + let coordinator = ChatCoordinator.makeForTesting() + let ownerA = WriterOwner() + let ownerB = WriterOwner() + let seeded = makeMessage() + + let writerA = try #require(coordinator.bindWriter(owner: ownerA, role: .interactive)) + writerA.replaceAll([seeded]) + #expect(coordinator.messages.count == 1) + + let writerB = try #require(coordinator.bindWriter(owner: ownerB, role: .interactive)) + _ = writerB + + let renderStateIDBefore = coordinator.renderStateID + let messagesBefore = coordinator.messages + let renderStateBefore = coordinator.renderState + + let extra = makeMessage(text: "stale append") + writerA.replaceAll([]) + #expect(writerA.append(extra) == false) + writerA.prepend([extra]) + writerA.update(messageID: seeded.id) { $0.text = "stale edit" } + writerA.remove(messageID: seeded.id) + writerA.replaceMessagesPreservingByID([]) + writerA.beginLoading() + writerA.markLoaded() + writerA.updateRenderState { $0.with(isLoadingOlder: true) } + writerA.updateRenderItem(id: seeded.id) { $0 } + writerA.removeRenderItem(id: seeded.id) + writerA.applyStatusUpdate(messageID: seeded.id, status: .failed) + writerA.rebuildItems(inputs: [], envInputs: .default) + writerA.enqueueReload(messageID: seeded.id) + + #expect(coordinator.renderStateID == renderStateIDBefore) + #expect(coordinator.messages == messagesBefore) + #expect(coordinator.renderState == renderStateBefore) + #expect(coordinator.messagesByID[seeded.id]?.text == "hello") + } + + @Test + func `current writer mutations apply`() throws { + let coordinator = ChatCoordinator.makeForTesting() + let owner = WriterOwner() + let writer = try #require(coordinator.bindWriter(owner: owner, role: .interactive)) + let message = makeMessage() + + #expect(writer.append(message)) + #expect(coordinator.messages.count == 1) + + writer.update(messageID: message.id) { $0.text = "edited" } + #expect(coordinator.messagesByID[message.id]?.text == "edited") + + writer.remove(messageID: message.id) + #expect(coordinator.messages.isEmpty) + } + + @Test + func `prime bind is denied while an interactive owner is alive`() throws { + let coordinator = ChatCoordinator.makeForTesting() + let interactiveOwner = WriterOwner() + let primeOwner = WriterOwner() + + let interactive = try #require(coordinator.bindWriter(owner: interactiveOwner, role: .interactive)) + #expect(coordinator.bindWriter(owner: primeOwner, role: .prime) == nil) + #expect(interactive.isCurrent) + } + + @Test + func `prime bind succeeds over a vacant slot, a prior prime, and a deallocated owner`() throws { + let coordinator = ChatCoordinator.makeForTesting() + + // Vacant slot. + let primeOwnerA = WriterOwner() + let primeA = try #require(coordinator.bindWriter(owner: primeOwnerA, role: .prime)) + + // Prior prime. + let primeOwnerB = WriterOwner() + let primeB = try #require(coordinator.bindWriter(owner: primeOwnerB, role: .prime)) + #expect(!primeA.isCurrent) + #expect(primeB.isCurrent) + + func bindTransientInteractiveOwner() { + let owner = WriterOwner() + #expect(coordinator.bindWriter(owner: owner, role: .interactive) != nil) + } + // Deallocated interactive owner frees the slot: the owner's only + // strong reference dies with this scope (the coordinator holds it + // weakly), so the follow-up prime bind sees a vacant slot. + bindTransientInteractiveOwner() + let primeOwnerC = WriterOwner() + #expect(coordinator.bindWriter(owner: primeOwnerC, role: .prime) != nil) + } + + @Test + func `interactive bind swaps hooks atomically with write ownership`() { + let coordinator = ChatCoordinator.makeForTesting() + let ownerA = WriterOwner() + let ownerB = WriterOwner() + var rebuilderHits: [String] = [] + + let primeWriter = coordinator.bindWriter( + owner: ownerA, + role: .prime, + renderItemRebuilder: { _ in rebuilderHits.append("prime") } + ) + let interactiveWriter = coordinator.bindWriter( + owner: ownerB, + role: .interactive, + renderItemRebuilder: { _ in rebuilderHits.append("interactive") } + ) + #expect(primeWriter != nil) + #expect(interactiveWriter != nil) + + coordinator.renderItemRebuilder?(UUID()) + #expect(rebuilderHits == ["interactive"]) + } + + @Test + func `stale writer cannot schedule a full rebuild`() throws { + let coordinator = ChatCoordinator.makeForTesting() + let ownerA = WriterOwner() + let ownerB = WriterOwner() + let message = makeMessage() + + let writerA = try #require(coordinator.bindWriter(owner: ownerA, role: .prime)) + writerA.replaceAll([message]) + + _ = try #require(coordinator.bindWriter(owner: ownerB, role: .interactive)) + let renderStateIDBefore = coordinator.renderStateID + + // `rebuildItems` bumps the generation counter before capturing it + // (last-scheduled-wins), which is exactly why a stale writer must not + // reach it: this call must leave the counter untouched. + writerA.rebuildItems(inputs: [], envInputs: .default) + #expect(coordinator.renderStateID == renderStateIDBefore) + #expect(coordinator.buildItemsTask == nil) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/InlineImageDimensionsStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/InlineImageDimensionsStoreTests.swift index 1d94e755..8eac140d 100644 --- a/MC1Services/Tests/MC1ServicesTests/InlineImageDimensionsStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/InlineImageDimensionsStoreTests.swift @@ -93,15 +93,20 @@ struct InlineImageDimensionsStoreTests { } @Test - func `resolutionStream emits the URL on save`() async throws { + func `resolutionUpdates emits the URL on save`() async throws { let fileURL = Self.makeTempFileURL() defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } let store = InlineImageDimensionsStore(fileURL: fileURL) let url = try #require(URL(string: "https://example.com/stream.png")) + // Subscribe before saving: registration is synchronous, and the + // multicast stream delivers only events yielded after subscription. + // Every consumer follows this contract; view models subscribe at + // configure time, before any fetch can save. + let stream = store.resolutionUpdates() let receiveTask = Task { - for await emitted in store.resolutionStream { + for await emitted in stream { return emitted } return nil @@ -121,6 +126,52 @@ struct InlineImageDimensionsStoreTests { #expect(received == url) } + @Test + func `resolutionUpdates delivers every event to every concurrent subscriber`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let urlA = try #require(URL(string: "https://example.com/multicast-a.png")) + let urlB = try #require(URL(string: "https://example.com/multicast-b.png")) + + // Subscribe both streams before consuming: registration is synchronous, + // so every event yielded after these lines reaches both. Two live + // subscribers (iPad split view) must each receive every URL; a + // single-consumer stream would split events between them. + let streamOne = store.resolutionUpdates() + let streamTwo = store.resolutionUpdates() + + func collectTwo(_ stream: AsyncStream) -> Task<[URL], Never> { + Task { + var received: [URL] = [] + for await url in stream { + received.append(url) + if received.count == 2 { break } + } + return received + } + } + let consumerOne = collectTwo(streamOne) + let consumerTwo = collectTwo(streamTwo) + + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: Self.streamWaitNanoseconds) + consumerOne.cancel() + consumerTwo.cancel() + } + + await store.save(url: urlA, size: CGSize(width: 200, height: 100)) + await store.save(url: urlB, size: CGSize(width: 300, height: 100)) + + let receivedOne = await consumerOne.value + let receivedTwo = await consumerTwo.value + timeoutTask.cancel() + + #expect(receivedOne == [urlA, urlB]) + #expect(receivedTwo == [urlA, urlB]) + } + @Test func `round-trip: recreated store reads previously persisted aspect`() async throws { let fileURL = Self.makeTempFileURL() diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift index 271e0e4b..e3dbd4f8 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift @@ -5,6 +5,25 @@ import Testing @Suite("ChatCoordinator") @MainActor struct ChatCoordinatorTests { + @Test + func `initialPageSize keeps the standard page when unread fits within it`() { + #expect(ChatCoordinator.initialPageSize(unreadCount: 0) == ChatCoordinator.pageSize) + #expect(ChatCoordinator.initialPageSize(unreadCount: 10) == ChatCoordinator.pageSize) + // Boundary: unread + context still under a page stays at pageSize. + let underFit = ChatCoordinator.pageSize - ChatCoordinator.dividerReadContext - 1 + #expect(ChatCoordinator.initialPageSize(unreadCount: underFit) == ChatCoordinator.pageSize) + } + + @Test + func `initialPageSize grows to cover all unread plus read context`() { + // Unread beyond one page must all load at once, else the first-unread message + // (where the divider sits) would only page in later and the jump would have no target. + let unread = ChatCoordinator.pageSize + 70 + let limit = ChatCoordinator.initialPageSize(unreadCount: unread) + #expect(limit == unread + ChatCoordinator.dividerReadContext) + #expect(limit > unread, "Every unread message plus context must fit in the first page") + } + @Test func `append adds a new message and bumps renderStateID`() { let coordinator = ChatCoordinator.makeForTesting() diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index b7a6ba5b..fee2ab26 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -80,7 +80,7 @@ struct SimulatorSeedTests { // Unique-id upsert means a second pass does not duplicate rows. let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.count == 3) + #expect(channels.count == 4) let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) #expect(repeats.count == 3) let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) diff --git a/MC1Tests/Formatters/MessagePathFormatterTests.swift b/MC1Tests/Formatters/MessagePathFormatterTests.swift index b467065d..5ad5d55c 100644 --- a/MC1Tests/Formatters/MessagePathFormatterTests.swift +++ b/MC1Tests/Formatters/MessagePathFormatterTests.swift @@ -44,57 +44,6 @@ struct MessagePathFormatterTests { #expect(result == "A3,7F,42") } - @Test - func `Four node path shows all nodes`() { - let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2") - } - - // MARK: - Truncation Tests - - @Test - func `Six node path shows all nodes (no truncation)`() { - let message = createMessage( - pathLength: 6, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1,D4") - } - - @Test - func `Seven node path truncates with ellipsis`() { - let message = createMessage( - pathLength: 7, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…C1,D4,E5") - } - - @Test - func `Ten node path truncates correctly`() { - let message = createMessage( - pathLength: 10, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5, 0xF6, 0x11, 0x22]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…F6,11,22") - } - - // MARK: - Boundary & Edge Case Tests - - @Test - func `Five node path shows all nodes (boundary before truncation)`() { - let message = createMessage( - pathLength: 5, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1") - } - @Test func `Zero-byte node formats correctly`() { let message = createMessage(pathLength: 2, pathNodes: Data([0x00, 0xA3])) @@ -143,6 +92,20 @@ struct MessagePathFormatterTests { #expect(MessagePathFormatter.format(message) == "010203,040506") } + // MARK: - Truncation Tests + + @Test + func `Four-node path is shown in full (at the cap)`() { + let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) + #expect(MessagePathFormatter.format(message) == "A3,7F,42,B2") + } + + @Test + func `Path longer than four nodes collapses the middle to a tight ellipsis`() { + let message = createMessage(pathLength: 6, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD0])) + #expect(MessagePathFormatter.format(message) == "A3,7F…C1,D0") + } + // MARK: - Helper private func createMessage(pathLength: UInt8, pathNodes: Data?) -> MessageDTO { diff --git a/MC1Tests/State/ChatPrewarmRefresherTests.swift b/MC1Tests/State/ChatPrewarmRefresherTests.swift new file mode 100644 index 00000000..324414f6 --- /dev/null +++ b/MC1Tests/State/ChatPrewarmRefresherTests.swift @@ -0,0 +1,324 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftData +import Testing + +/// Exercises the arrival-time refresh of warm chat coordinators. A coordinator +/// prewarmed while its chat is closed must not keep its prime-time tail: +/// reopening would render the stale list on the first frame and the fresh +/// fetch would land as an offset-preserving tail append, leaving the view +/// scrolled above the new messages instead of at the bottom. +@Suite("ChatPrewarmRefresher", .serialized) +@MainActor +struct ChatPrewarmRefresherTests { + // MARK: - Fixtures + + private func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeDependencies(dataStore: PersistenceStore) -> ChatViewModel.Dependencies { + ChatViewModel.Dependencies( + dataStore: { dataStore }, + messageService: { nil }, + notificationService: { nil }, + channelService: { nil }, + roomServerService: { nil }, + contactService: { nil }, + syncCoordinator: { nil }, + connectionState: { .disconnected }, + connectedDevice: { nil }, + currentRadioID: { nil }, + session: { nil }, + reactionService: { nil }, + chatSendQueueService: { nil }, + inlineImageDimensionsStore: { nil }, + prefetchDataStore: { nil } + ) + } + + private func makeHooks( + registry: ChatCoordinatorRegistry, + dataStore: PersistenceStore, + isActive: @escaping @MainActor (ChatPrewarmRefresher.ConversationKind) -> Bool = { _ in false } + ) -> ChatPrewarmRefresher.Hooks { + ChatPrewarmRefresher.Hooks( + registry: { registry }, + dependencies: { [self] in makeDependencies(dataStore: dataStore) }, + envInputs: { _ in .default }, + isConversationActive: isActive, + channel: { radioID, index in + await (try? dataStore.fetchChannel(radioID: radioID, index: index)) ?? nil + }, + contact: { _, contactID in + await (try? dataStore.fetchContact(id: contactID)) ?? nil + }, + linkPreviewCache: { nil } + ) + } + + private func makeChannel(radioID: UUID, index: UInt8 = 0) -> ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: radioID, + index: index, + name: "TestChannel", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private func makeChannelMessage(radioID: UUID, channelIndex: UInt8 = 0, timestamp: UInt32, text: String) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "Sender", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeContact(radioID: UUID, id: UUID = UUID(), unreadCount: Int = 0) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: Data((0.. MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + /// Warms the shared coordinator the way `AppState.prefetchConversation` does: + /// a throwaway view model primes it, then is discarded. + private func warmCoordinator( + registry: ChatCoordinatorRegistry, + dataStore: PersistenceStore, + conversation: ChatConversationType + ) async { + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: makeDependencies(dataStore: dataStore), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: registry, + conversation: conversation + ) + viewModel.applyEnvInputs(.default) + switch conversation { + case let .dm(contact): + await viewModel.primeInitialMessages(for: contact) + case let .channel(channel): + await viewModel.primeInitialChannelMessages(for: channel) + } + } + + // MARK: - Tests + + @Test + func `channel arrival re-primes a warm coordinator with the new tail`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + try await dataStore.saveMessage(makeChannelMessage(radioID: radioID, timestamp: 1000, text: "old")) + + await warmCoordinator(registry: registry, dataStore: dataStore, conversation: .channel(channel)) + let id = ChatConversationID.channel(radioID: radioID, channelIndex: channel.index) + let coordinator = try #require(registry.existingCoordinator(for: id)) + #expect(coordinator.messages.count == 1) + + // Message arrives while the chat is closed. + let newMessage = makeChannelMessage(radioID: radioID, timestamp: 2000, text: "new") + try await dataStore.saveMessage(newMessage) + + let refresher = ChatPrewarmRefresher( + hooks: makeHooks(registry: registry, dataStore: dataStore), + debounce: .zero + ) + refresher.noteChannelMessage(radioID: radioID, channelIndex: channel.index) + // A second arrival in the same window coalesces onto the scheduled refresh. + refresher.noteChannelMessage(radioID: radioID, channelIndex: channel.index) + #expect(refresher.inFlight.count == 1) + + let task = try #require(refresher.inFlight[id]) + await task.value + + #expect(coordinator.messages.count == 2) + #expect(coordinator.messages.last?.id == newMessage.id) + await coordinator.buildItemsTask?.value + #expect(coordinator.renderState.items.contains { $0.id == newMessage.id }) + #expect(refresher.inFlight.isEmpty) + } + + @Test + func `direct-message arrival re-primes a warm coordinator with the new tail`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await dataStore.saveMessage(makeDirectMessage(radioID: radioID, contactID: contact.id, timestamp: 1000, text: "old")) + + await warmCoordinator(registry: registry, dataStore: dataStore, conversation: .dm(contact)) + let id = ChatConversationID.dm(radioID: radioID, contactID: contact.id) + let coordinator = try #require(registry.existingCoordinator(for: id)) + #expect(coordinator.messages.count == 1) + + let newMessage = makeDirectMessage(radioID: radioID, contactID: contact.id, timestamp: 2000, text: "new") + try await dataStore.saveMessage(newMessage) + + let refresher = ChatPrewarmRefresher( + hooks: makeHooks(registry: registry, dataStore: dataStore), + debounce: .zero + ) + refresher.noteDirectMessage(contact: contact) + + let task = try #require(refresher.inFlight[id]) + await task.value + + #expect(coordinator.messages.count == 2) + #expect(coordinator.messages.last?.id == newMessage.id) + } + + @Test + func `direct-message refresh bakes the divider from the store's fresh unread count`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let contactID = UUID() + // The event-time DTO carries the pre-increment count: fully read at capture. + let staleContact = makeContact(radioID: radioID, id: contactID, unreadCount: 0) + try await dataStore.saveMessage(makeDirectMessage(radioID: radioID, contactID: contactID, timestamp: 1000, text: "old")) + + await warmCoordinator(registry: registry, dataStore: dataStore, conversation: .dm(staleContact)) + let id = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinator = try #require(registry.existingCoordinator(for: id)) + await coordinator.buildItemsTask?.value + #expect(coordinator.renderState.newMessagesDividerItemID == nil) + + // A message arrives while the chat is closed; the store now records one + // unread for the contact, later than the event-time DTO. + let newMessage = makeDirectMessage(radioID: radioID, contactID: contactID, timestamp: 2000, text: "new") + try await dataStore.saveMessage(newMessage) + try await dataStore.saveContact(makeContact(radioID: radioID, id: contactID, unreadCount: 1)) + + let refresher = ChatPrewarmRefresher( + hooks: makeHooks(registry: registry, dataStore: dataStore), + debounce: .zero + ) + // Prime with the stale, pre-increment DTO the dispatcher would carry. + refresher.noteDirectMessage(contact: staleContact) + + let task = try #require(refresher.inFlight[id]) + await task.value + + await coordinator.buildItemsTask?.value + #expect(coordinator.messages.count == 2) + // The divider bakes from the store's fresh count (1), landing on the new + // message, rather than the event-time DTO's zero which shows no divider. + #expect(coordinator.renderState.newMessagesDividerItemID == newMessage.id) + } + + @Test + func `an open conversation is not refreshed`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + try await dataStore.saveMessage(makeChannelMessage(radioID: radioID, timestamp: 1000, text: "old")) + + await warmCoordinator(registry: registry, dataStore: dataStore, conversation: .channel(channel)) + let id = ChatConversationID.channel(radioID: radioID, channelIndex: channel.index) + let coordinator = try #require(registry.existingCoordinator(for: id)) + + try await dataStore.saveMessage(makeChannelMessage(radioID: radioID, timestamp: 2000, text: "new")) + + let refresher = ChatPrewarmRefresher( + hooks: makeHooks(registry: registry, dataStore: dataStore, isActive: { _ in true }), + debounce: .zero + ) + refresher.noteChannelMessage(radioID: radioID, channelIndex: channel.index) + + #expect(refresher.inFlight.isEmpty) + #expect(coordinator.messages.count == 1) + } + + @Test + func `a cold conversation is ignored and no coordinator is created`() throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + + let refresher = ChatPrewarmRefresher( + hooks: makeHooks(registry: registry, dataStore: dataStore), + debounce: .zero + ) + refresher.noteChannelMessage(radioID: radioID, channelIndex: 0) + + #expect(refresher.inFlight.isEmpty) + #expect(registry.existingCoordinator(for: .channel(radioID: radioID, channelIndex: 0)) == nil) + } +} diff --git a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift b/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift deleted file mode 100644 index 9b1226ab..00000000 --- a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -@testable import MC1 -import Testing - -@Suite("ChatScrollToMentionPolicy Tests") -struct ChatScrollToMentionPolicyTests { - @Test - func `shouldScrollToBottom returns false when mentionTargetID is nil`() { - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: nil, newestItemID: newestID) == false) - } - - @Test - func `shouldScrollToBottom returns false when newestItemID is nil`() { - let mentionID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: nil) == false) - } - - @Test - func `shouldScrollToBottom returns false when IDs differ`() { - let mentionID = UUID() - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: newestID) == false) - } - - @Test - func `shouldScrollToBottom returns true when IDs match`() { - let id = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: id, newestItemID: id) == true) - } - - @Test - func `nextTarget returns nil when there are no off-screen mentions`() { - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: []) == nil) - } - - @Test - func `nextTarget picks the newest (last) of an oldest-to-newest list`() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: [oldest, middle, newest]) == newest) - } - - @Test - func `nextTarget walks upward to the earliest as newer targets are consumed`() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - var remaining = [oldest, middle, newest] - - var visited: [UUID] = [] - while let target = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: remaining) { - visited.append(target) - remaining.removeAll { $0 == target } - } - - #expect(visited == [newest, middle, oldest]) - } -} diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index f5d258ae..85ad7cd4 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -119,6 +119,27 @@ private func createChannelMessage( ) } +/// A copy of `EnvInputs.default` with one formatting-relevant field flipped, +/// so `applyEnvInputs` sees a change and invalidates the formatted-text cache. +private func envInputsChangingAppearance() -> EnvInputs { + let base = EnvInputs.default + return EnvInputs( + autoPlayGIFs: base.autoPlayGIFs, + showIncomingPath: base.showIncomingPath, + showIncomingHopCount: base.showIncomingHopCount, + showIncomingRegion: base.showIncomingRegion, + showIncomingSendTime: base.showIncomingSendTime, + previewsEnabled: base.previewsEnabled, + isHighContrast: base.isHighContrast, + isDark: !base.isDark, + showMapPreviews: base.showMapPreviews, + isOffline: base.isOffline, + currentUserName: base.currentUserName, + themeID: base.themeID, + contentSizeCategory: base.contentSizeCategory + ) +} + // MARK: - Pagination Tests @Suite("ChatViewModel Pagination Tests") @@ -133,7 +154,7 @@ struct ChatViewModelPaginationTests { viewModel.currentContact = contact let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) // Without configuring dataStore, loadOlderMessages should return early await viewModel.loadOlderMessages() @@ -168,7 +189,7 @@ struct ChatViewModelPaginationTests { viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) viewModel.currentContact = createTestContact(id: contactID, radioID: radioID) let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) // Seed the database with a page worth of messages so the // pagination fetch returns rows and proceeds past the @@ -198,6 +219,66 @@ struct ChatViewModelPaginationTests { @Suite("ChatViewModel Channel Pagination Tests") @MainActor struct ChatViewModelChannelPaginationTests { + @Test + func `Opening a channel with more unread than one page loads the divider target`() async throws { + // When unread exceeds pageSize, the first-unread message (where the divider + // belongs) is older than a standard page. The initial load must be sized to + // cover all unread so the divider has a materialized message to scroll to, + // instead of clamping the divider onto the oldest loaded row. + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 0 + let total = 80 + let unread = 60 + #expect(unread > ChatCoordinator.pageSize, "Fixture must exceed one page to exercise the fix") + + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: channelIndex, + name: "Mesh HQ", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: unread, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + try await dataStore.saveChannel(channel) + + // Ascending timestamps → display order is oldest-first; the first unread row + // sits at index total - unread. + var idsOldestFirst: [UUID] = [] + for index in 0.. MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: text, + timestamp: 1000, + createdAt: Date(timeIntervalSince1970: 1000), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "Sender", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `build inputs detect the message URL synchronously`() { + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + let message = makeMessage(text: "look at https://example.com/article") + + let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + + #expect(inputs.cachedURL == URL(string: "https://example.com/article")) + #expect(viewModel.cachedURLs[message.id] != nil, + "detection must be recorded so later rebuilds skip the scan") + } + + @Test + func `build inputs rehydrate a decoded preview in the same call`() throws { + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + // Unique URL: DecodedPreviewCache is a process singleton shared by tests. + let urlString = "https://example.com/\(UUID().uuidString)" + let message = makeMessage(text: "see \(urlString)") + let url = try #require(URL(string: urlString)) + + DecodedPreviewCache.shared.store( + CachedDecodedPreview( + dto: LinkPreviewDataDTO(url: urlString, title: "Example", imageWidth: 1200, imageHeight: 630), + hero: nil, + icon: nil + ), + for: url + ) + + let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + + #expect(inputs.previewState == .loaded, + "a decoded-cache hit must paint .loaded in the same build, skipping the shimmer") + #expect(viewModel.loadedPreviews[message.id]?.title == "Example") + } + + @Test + func `build inputs resolve the remembered hero aspect for the shimmer`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("PreviewSeedTests-\(UUID().uuidString).json") + let dimensionsStore = InlineImageDimensionsStore(fileURL: storeURL) + defer { try? FileManager.default.removeItem(at: storeURL) } + + let urlString = "https://example.com/\(UUID().uuidString)" + let url = try #require(URL(string: urlString)) + await dimensionsStore.save(url: url, size: CGSize(width: 1200, height: 630)) + + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { nil }, + messageService: { nil }, + notificationService: { nil }, + channelService: { nil }, + roomServerService: { nil }, + contactService: { nil }, + syncCoordinator: { nil }, + connectionState: { .disconnected }, + connectedDevice: { nil }, + currentRadioID: { nil }, + session: { nil }, + reactionService: { nil }, + chatSendQueueService: { nil }, + inlineImageDimensionsStore: { dimensionsStore }, + prefetchDataStore: { nil } + ), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: nil, + conversation: nil + ) + let message = makeMessage(text: "see \(urlString)") + + let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + + let aspect = try #require(inputs.previewHeroAspect) + #expect(abs(aspect - 1200.0 / 630.0) < 0.001, + "the loading shimmer must reserve the remembered hero footprint") + } + + @Test + func `messages without URLs record the negative result and stay fragment-free`() { + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + let message = makeMessage(text: "no links here") + + let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + + #expect(inputs.cachedURL == nil) + // The dictionary is `[UUID: URL?]`, so a stored negative result is the double + // optional `.some(nil)`: the key is present (outer `.some`) while the detected + // URL is nil (inner `.none`). Rebuilds key on presence to skip re-scanning. + #expect(viewModel.cachedURLs[message.id] != nil, + "the detected-no-URL sentinel must be stored so rebuilds skip re-scanning") + #expect(viewModel.cachedURLs[message.id] == .some(nil)) + } +} diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index f7da5353..24c7cad9 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -252,6 +252,31 @@ struct ChatViewModelTests { #expect(vm.newMessagesDividerMessageID == messages[0].id) } + @Test + @MainActor + func `Divider shows for a single unread message`() { + // Threshold is zero: any unread backlog gets a divider. The one-unread case + // lands the divider on the last row (count - 1). + let vm = ChatViewModel() + let messages = (0..<5).map { createTestMessage(timestamp: UInt32(1000 + $0), text: "m\($0)") } + + vm.computeDividerPosition(from: messages, unreadCount: 1, isDM: true) + + #expect(vm.newMessagesDividerMessageID == messages[4].id) + } + + @Test + @MainActor + func `Divider absent when there are no unread messages`() { + // Zero unread must not produce a divider even at the zero threshold. + let vm = ChatViewModel() + let messages = (0..<5).map { createTestMessage(timestamp: UInt32(1000 + $0), text: "m\($0)") } + + vm.computeDividerPosition(from: messages, unreadCount: 0, isDM: true) + + #expect(vm.newMessagesDividerMessageID == nil) + } + @Test func `Mixed gaps show correct timestamps`() { let baseTime: UInt32 = 1000 @@ -276,8 +301,8 @@ struct ChatViewModelTests { func `buildItems with empty messages produces empty output`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - coordinator.replaceAll([]) + viewModel.bindCoordinatorForTesting(coordinator) + coordinator.replaceAllForTesting([]) viewModel.buildItems() await coordinator.buildItemsTask?.value @@ -290,7 +315,7 @@ struct ChatViewModelTests { func `buildItems clears stale mapPreviewRequestIndex so theme-toggle keys do not leak`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) // Outgoing message so coordinate-text path runs without sender-name resolution. let message = createTestMessage(timestamp: 1000, text: "see 37.7749, -122.4194") @@ -327,7 +352,7 @@ struct ChatViewModelTests { func `a themeID-only EnvInputs change rebuilds items with newly baked theme colors`() async throws { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) // The hashtag run bakes hashtagColor, which differs between default and ember, so a // themeID change must produce a different MessageItem. This guards the deliberate baking of @@ -484,9 +509,9 @@ struct ChatViewModelTests { func `renderState.phase is .loaded after replaceAll on bound coordinator`() { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) - coordinator.replaceAll([]) + coordinator.replaceAllForTesting([]) #expect(viewModel.renderState.phase == .loaded) #expect(viewModel.messages.isEmpty) @@ -496,7 +521,7 @@ struct ChatViewModelTests { func `loadMessages settles phase to .loaded when dataStore is nil`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) await viewModel.loadMessages(for: createTestContact()) @@ -507,7 +532,7 @@ struct ChatViewModelTests { func `loadChannelMessages settles phase to .loaded when dataStore is nil`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let channel = ChannelDTO(from: Channel( radioID: UUID(), @@ -915,7 +940,7 @@ struct ChatViewModelImageGatingTests { ) -> ChatViewModel { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) viewModel.appendMessageIfNew(message) viewModel.cachedURLs[message.id] = imageURL viewModel.envInputs = makeEnv(previewsEnabled: previewsEnabled) diff --git a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift new file mode 100644 index 00000000..5456c168 --- /dev/null +++ b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift @@ -0,0 +1,240 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// A cold prime `ChatViewModel` (navigation prefetch, arrival-time refresh) +/// sharing a live conversation's `ChatCoordinator` must never bake items +/// from its own empty preview state over the live view model's loaded +/// items: `ChatTimelineWriter` drops stale writes at the coordinator, and +/// the request-path self-heal repairs any row that still desyncs. +@Suite("Chat timeline clobber regression") +@MainActor +struct ChatTimelineClobberRegressionTests { + private func makeRegistry() throws -> ChatCoordinatorRegistry { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + return ChatCoordinatorRegistry(dataStore: dataStore) + } + + private func makeLinkMessage(radioID: UUID, contactID: UUID) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "check this out https://example.com/article", + timestamp: 1000, + createdAt: Date(timeIntervalSince1970: 1000), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + /// Binds `viewModel` to `coordinator` the way `bindCoordinator` does: + /// writer and rebuild hooks installed as one act. + private func bind(_ viewModel: ChatViewModel, to coordinator: ChatCoordinator, role: ChatWriterRole) { + viewModel.attachCoordinator(coordinator) + viewModel.timelineWriter = coordinator.bindWriter( + owner: viewModel, + role: role, + renderItemRebuilder: { [weak viewModel] messageID in + viewModel?.rebuildDisplayItem(for: messageID) + }, + renderStateInvalidated: { [weak viewModel] in + viewModel?.buildItems() + } + ) + } + + @Test + func `a stale prime view model cannot bake its cold state over the live timeline`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let contactID = UUID() + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let message = makeLinkMessage(radioID: radioID, contactID: contactID) + + // The hazardous ordering: the prime binds first (conversation closed), + // the user then opens the chat, and the prime's in-flight work lands + // late. + let primeVM = ChatViewModel() + bind(primeVM, to: coordinator, role: .prime) + #expect(primeVM.timelineWriter != nil) + + let liveVM = ChatViewModel() + bind(liveVM, to: coordinator, role: .interactive) + let liveWriter = try #require(liveVM.timelineWriter) + #expect(liveWriter.isCurrent) + + // Live view model appends the row and settles its preview state; the + // baked item no longer carries the shimmer fragment. + liveVM.appendMessageIfNew(message) + liveVM.previewStates[message.id] = .noPreview + liveVM.rebuildDisplayItem(for: message.id) + let settledItem = try #require(coordinator.renderState.items.first { $0.id == message.id }) + let renderStateIDAfterSettle = coordinator.renderStateID + + // The stale prime's late rebuild: cold previewStates would bake the + // shimmer back. The stale writer must drop it at the coordinator. + primeVM.rebuildDisplayItem(for: message.id) + + let itemAfter = try #require(coordinator.renderState.items.first { $0.id == message.id }) + #expect(itemAfter == settledItem) + #expect(coordinator.renderStateID == renderStateIDAfterSettle) + } + + @Test + func `a prime bind is denied while the conversation is open`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let contactID = UUID() + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let message = makeLinkMessage(radioID: radioID, contactID: contactID) + + let liveVM = ChatViewModel() + bind(liveVM, to: coordinator, role: .interactive) + liveVM.appendMessageIfNew(message) + let itemBefore = try #require(coordinator.renderState.items.first { $0.id == message.id }) + + // A prime arriving while the chat is open gets no writer, and its + // rebuild attempts are inert. + let primeVM = ChatViewModel() + bind(primeVM, to: coordinator, role: .prime) + #expect(primeVM.timelineWriter == nil) + + primeVM.rebuildDisplayItem(for: message.id) + let itemAfter = try #require(coordinator.renderState.items.first { $0.id == message.id }) + #expect(itemAfter == itemBefore) + + // The live view model keeps both its hooks and its write access. + #expect(liveVM.timelineWriter?.isCurrent == true) + } + + @Test + func `request-path self-heal rebakes a row that desynced from settled preview state`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let contactID = UUID() + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let message = makeLinkMessage(radioID: radioID, contactID: contactID) + + let liveVM = ChatViewModel() + bind(liveVM, to: coordinator, role: .interactive) + + // Bake the row while the preview state is `.idle`: the item carries + // the shimmer fragment and asks for a fetch. + liveVM.appendMessageIfNew(message) + let shimmerItem = try #require(coordinator.renderState.items.first { $0.id == message.id }) + #expect(shimmerItem.shouldRequestPreviewFetch) + + // State settles without the item being rebaked. The next fetch + // request must repair the row instead of silently skipping. + liveVM.previewStates[message.id] = .noPreview + liveVM.requestPreviewFetch(for: message.id) + + let repairedItem = try #require(coordinator.renderState.items.first { $0.id == message.id }) + #expect(repairedItem != shimmerItem) + #expect(!repairedItem.shouldRequestPreviewFetch) + } + + @Test + func `first load of a fresh view model does not cancel in-flight preview fetches`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contactID = UUID() + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + + // A warm-coordinator cell fired its fetch before the load task ran, + // matching the open-time ordering: cells render from the warm + // coordinator on the first frame, `performInitialLoad` runs afterwards. + let message = makeLinkMessage(radioID: radioID, contactID: contactID) + viewModel.previewStates[message.id] = .loading + let fetchStandIn = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + viewModel.previewFetchTasks[message.id] = fetchStandIn + + // First-ever load on this view model: not a conversation switch, so it + // must not clear preview state or cancel the in-flight fetch. + let contact = ContactDTO(from: Contact( + id: contactID, + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: ProtocolLimits.publicKeySize), + name: "Fixture", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + )) + _ = await viewModel.primeInitialMessages(for: contact) + + #expect(!fetchStandIn.isCancelled) + #expect(viewModel.previewStates[message.id] == .loading) + #expect(viewModel.previewFetchTasks[message.id] != nil) + fetchStandIn.cancel() + + // Loading a DIFFERENT conversation afterwards is a switch and must + // clear. + let otherContact = ContactDTO(from: Contact( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0xCD, count: ProtocolLimits.publicKeySize), + name: "Other", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + )) + _ = await viewModel.primeInitialMessages(for: otherContact) + #expect(viewModel.previewStates[message.id] == nil) + } + + @Test + func `an orphaned loading state resets so the fetch can re-fire`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let contactID = UUID() + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let message = makeLinkMessage(radioID: radioID, contactID: contactID) + + let liveVM = ChatViewModel() + bind(liveVM, to: coordinator, role: .interactive) + liveVM.appendMessageIfNew(message) + + // `.loading` with no in-flight task can be reached through in-flight + // dedup stranding. The request path must treat it as orphaned rather + // than deadlocking in shimmer. + liveVM.previewStates[message.id] = .loading + liveVM.requestPreviewFetch(for: message.id) + + #expect(liveVM.previewStates[message.id] != .loading) + } +} diff --git a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift index 7d2ac858..2844122c 100644 --- a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift @@ -279,7 +279,7 @@ struct ChatViewModelAdmissionTests { private func makeBoundViewModel() -> ChatViewModel { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) return viewModel } diff --git a/MC1Tests/Views/Chats/ChatViewModelAppendRaceTests.swift b/MC1Tests/Views/Chats/ChatViewModelAppendRaceTests.swift index 1b062c4a..ce4d944a 100644 --- a/MC1Tests/Views/Chats/ChatViewModelAppendRaceTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelAppendRaceTests.swift @@ -16,7 +16,7 @@ struct ChatViewModelAppendRaceTests { func `appendMessageIfNew skips a message already present in messagesByID`() { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = MessageDTO( id: UUID(), diff --git a/MC1Tests/Views/Chats/ChatViewModelCoordinatorSharingTests.swift b/MC1Tests/Views/Chats/ChatViewModelCoordinatorSharingTests.swift index 5c70c587..c2a5c92d 100644 --- a/MC1Tests/Views/Chats/ChatViewModelCoordinatorSharingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelCoordinatorSharingTests.swift @@ -27,8 +27,8 @@ struct ChatViewModelCoordinatorSharingTests { let viewModelA = ChatViewModel() let viewModelB = ChatViewModel() - viewModelA.coordinator = registry.coordinator(for: id) - viewModelB.coordinator = registry.coordinator(for: id) + viewModelA.bindCoordinatorForTesting(registry.coordinator(for: id)) + viewModelB.bindCoordinatorForTesting(registry.coordinator(for: id)) let message = makeDirectMessage(radioID: radioID, contactID: contactID) _ = viewModelA.coordinator?.append(message) @@ -72,8 +72,8 @@ struct ChatViewModelCoordinatorSharingTests { let viewModelA = ChatViewModel() let viewModelB = ChatViewModel() - viewModelA.coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactA)) - viewModelB.coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactB)) + viewModelA.bindCoordinatorForTesting(registry.coordinator(for: .dm(radioID: radioID, contactID: contactA))) + viewModelB.bindCoordinatorForTesting(registry.coordinator(for: .dm(radioID: radioID, contactID: contactB))) let messageForA = makeDirectMessage(radioID: radioID, contactID: contactA) _ = viewModelA.coordinator?.append(messageForA) diff --git a/MC1Tests/Views/Chats/ChatViewModelEventGuardTests.swift b/MC1Tests/Views/Chats/ChatViewModelEventGuardTests.swift index c769e4a9..32e51f7f 100644 --- a/MC1Tests/Views/Chats/ChatViewModelEventGuardTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelEventGuardTests.swift @@ -43,7 +43,7 @@ struct ChatViewModelEventGuardTests { func `messageStatusResolved applies status in place via the coordinator`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage() _ = coordinator.append(message) @@ -70,7 +70,7 @@ struct ChatViewModelEventGuardTests { func `messageResent schedules a reload via the coordinator`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage(status: .failed) _ = coordinator.append(message) @@ -85,7 +85,7 @@ struct ChatViewModelEventGuardTests { func `messageFailed schedules a reload via the coordinator`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage() _ = coordinator.append(message) @@ -100,7 +100,7 @@ struct ChatViewModelEventGuardTests { func `heardRepeatRecorded schedules a reload via the coordinator`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage(status: .sent) _ = coordinator.append(message) @@ -115,7 +115,7 @@ struct ChatViewModelEventGuardTests { func `reactionReceived schedules a reload via the coordinator`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage(status: .delivered) _ = coordinator.append(message) @@ -130,7 +130,7 @@ struct ChatViewModelEventGuardTests { func `messageStatusResolved does not downgrade .delivered to .sent`() async { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage() _ = coordinator.append(message) diff --git a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift index 3d05234c..10b007e0 100644 --- a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift @@ -85,8 +85,8 @@ struct ChatViewModelReactionIndexingTests { private func makeViewModel(with message: MessageDTO) -> ChatViewModel { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - coordinator.replaceAll([message]) + viewModel.bindCoordinatorForTesting(coordinator) + coordinator.replaceAllForTesting([message]) return viewModel } diff --git a/MC1Tests/Views/Chats/Components/ChatScrollStateTests.swift b/MC1Tests/Views/Chats/Components/ChatScrollStateTests.swift deleted file mode 100644 index bcba8c1c..00000000 --- a/MC1Tests/Views/Chats/Components/ChatScrollStateTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -@testable import MC1 -import Testing - -@Suite("ChatScrollState") -struct ChatScrollStateTests { - @Test - func `dragging and applying coexist on independent axes`() { - var state: ChatScrollState = .idle - state.enterDragging() - state.startApplying() - #expect(state.interaction == .dragging) - #expect(state.apply == .applying) - } - - @Test - func `intent survives across interaction transitions`() { - var state: ChatScrollState = .idle - state.startIntent(.toBottom) - state.enterDragging() - #expect(state.intent == .toBottom) - state.endDragging() - #expect(state.intent == .toBottom) - } - - @Test - func `deferred scroll is consumed exactly once`() { - var state: ChatScrollState = .idle - state.scheduleDeferredScroll(DeferredScroll(targetMessageCount: 3)) - let first = state.consumeDeferredScroll() - let second = state.consumeDeferredScroll() - #expect(first?.targetMessageCount == 3) - #expect(second == nil) - } -} diff --git a/MC1Tests/Views/Chats/Components/ChatTableViewSnapshotRegressionTests.swift b/MC1Tests/Views/Chats/Components/ChatTableViewSnapshotRegressionTests.swift deleted file mode 100644 index e0bdfa4a..00000000 --- a/MC1Tests/Views/Chats/Components/ChatTableViewSnapshotRegressionTests.swift +++ /dev/null @@ -1,479 +0,0 @@ -@testable import MC1 -import SwiftUI -import Testing -import UIKit - -@Suite("ChatTableView Snapshot Regression Tests") -@MainActor -struct ChatTableViewSnapshotRegressionTests { - private struct TestMessageItem: Identifiable, Hashable { - let id: UUID - let text: String - let revision: Int - } - - private func waitForRowCount( - _ expectedCount: Int, - in controller: ChatTableViewController, - context: String - ) async throws { - try await waitUntil( - timeout: .seconds(30), - pollingInterval: .milliseconds(20), - "table rows should match expected count for \(context)" - ) { - controller.tableView.numberOfRows(inSection: 0) == expectedCount - } - } - - @Test - func `Pagination near-top callback is suppressed while older messages are loading`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - // Small dataset so all rows fall within the near-top threshold regardless of layout - let items = (0..<5).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(items, animated: false) - controller.tableView.layoutIfNeeded() - - var callCount = 0 - var capturedRelease: (@MainActor () -> Void)? - controller.onNearTop = { release in - callCount += 1 - capturedRelease = release - } - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - let baseline = callCount - #expect(baseline > 0, "Baseline should call onNearTop at least once when not loading") - - controller.isLoadingOlderMessages = true - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount == baseline, "onNearTop must be suppressed while loading older messages") - - controller.isLoadingOlderMessages = false - capturedRelease?() - capturedRelease = nil - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount > baseline, "onNearTop must resume after release is called") - } - - @Test - func `onNearTop latch suppresses duplicate fires until release is called`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let items = (0..<5).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(items, animated: false) - controller.tableView.layoutIfNeeded() - - var callCount = 0 - var capturedRelease: (@MainActor () -> Void)? - controller.onNearTop = { release in - callCount += 1 - capturedRelease = release - } - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - let baseline = callCount - #expect(baseline == 1, "First near-top tick should fire onNearTop once") - - // Multiple scroll ticks before release is called — the controller-owned - // latch must suppress them so the view model isn't bombarded with redundant Task spawns - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount == baseline, "Latch must suppress fires until release is called") - - // Simulate the consumer's pagination work completing - capturedRelease?() - capturedRelease = nil - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount == baseline + 1, "After release, the latch resets and the next near-top fires") - } - - @Test - func `onNearTop release clears latch even if isLoadingOlderMessages never flips`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let items = (0..<5).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(items, animated: false) - controller.tableView.layoutIfNeeded() - - var callCount = 0 - var capturedRelease: (@MainActor () -> Void)? - controller.onNearTop = { release in - callCount += 1 - capturedRelease = release - } - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount == 1) - - // Simulate the view model short-circuiting (e.g., hasMoreMessages == false) — - // isLoadingOlderMessages never transitions, but the consumer still calls release - capturedRelease?() - capturedRelease = nil - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(callCount == 2, "Release must clear the latch even when isLoadingOlder never flipped") - } - - @Test - func `Auto-scroll defers while user is dragging and fires on drag end`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let initialItems = (0..<5).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(initialItems, animated: false) - controller.tableView.layoutIfNeeded() - - controller.scrollViewWillBeginDragging(controller.tableView) - - var updatedItems = initialItems - updatedItems.append(TestMessageItem(id: UUID(), text: "Incoming during drag", revision: 0)) - controller.updateItems(updatedItems, animated: false) - - #expect(controller.deferredScrollToBottomPending, "Auto-scroll should defer while user is dragging") - - controller.scrollViewDidEndDragging(controller.tableView, willDecelerate: false) - - #expect(!controller.deferredScrollToBottomPending, "Deferred latch should clear when drag ends without deceleration") - } - - @Test - func `Deferred messages count as unread if user releases scrolled away from bottom`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let initialItems = (0..<50).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(initialItems, animated: false) - controller.tableView.layoutIfNeeded() - - let initialUnread = controller.unreadCount - - controller.scrollViewWillBeginDragging(controller.tableView) - - // Mid-drag before user has actually scrolled past the at-bottom threshold: - // wasAtBottom == true latches the deferred-scroll flag - var updatedItems = initialItems - updatedItems.append(TestMessageItem(id: UUID(), text: "Incoming during drag", revision: 0)) - controller.updateItems(updatedItems, animated: false) - - #expect(controller.deferredScrollToBottomPending) - #expect(controller.deferredScrollMessageCount == 1) - - // User now drags far away from bottom; isAtBottom flips false - controller.tableView.contentOffset.y = 500 - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(!controller.isAtBottom, "Scrolling past threshold should flip isAtBottom false") - - controller.scrollViewDidEndDragging(controller.tableView, willDecelerate: false) - - #expect(!controller.deferredScrollToBottomPending, "Deferred latch should clear after drag end") - #expect(controller.deferredScrollMessageCount == 0, "Deferred count should reset after drag end") - #expect(controller.unreadCount == initialUnread + 1, "Deferred message should count as unread when released away from bottom") - } - - @Test - func `Auto-scroll defers through deceleration and fires when decelerating ends`() { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let initialItems = (0..<5).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(initialItems, animated: false) - controller.tableView.layoutIfNeeded() - - controller.scrollViewWillBeginDragging(controller.tableView) - - var updatedItems = initialItems - updatedItems.append(TestMessageItem(id: UUID(), text: "Incoming during drag", revision: 0)) - controller.updateItems(updatedItems, animated: false) - - controller.scrollViewDidEndDragging(controller.tableView, willDecelerate: true) - #expect(controller.deferredScrollToBottomPending, "Latch should persist through deceleration phase") - - controller.scrollViewDidEndDecelerating(controller.tableView) - #expect(!controller.deferredScrollToBottomPending, "Deferred latch should clear after deceleration ends") - } - - @Test - func `Scroll-to-item row resolves from the applied snapshot, never the leading items model`() async throws { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let applied = (0..<10).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - controller.updateItems(applied, animated: false) - try await waitForRowCount(applied.count, in: controller, context: "seed") - - // Converged baseline: a present id resolves to an in-bounds applied-snapshot row. - let presentPath = try #require(controller.resolvedScrollRowForTests(id: applied[3].id)) - #expect(presentPath.row >= 0) - #expect(presentPath.row < controller.tableView.numberOfRows(inSection: 0)) - - // Reproduce the model-ahead-of-snapshot divergence that could abort scrollToRow: - // grow the items model by one without applying a snapshot, so the model (11) - // leads the applied row count (10). - let ghost = TestMessageItem(id: UUID(), text: "Message 10", revision: 0) - controller.advanceItemsModelWithoutApplyingForTests(applied + [ghost]) - #expect(controller.tableView.numberOfRows(inSection: 0) == applied.count) - - // An id in the model but not yet in the applied snapshot must resolve to nil. - // The old model math (items.count - 1 - itemIndex) would instead return row 0 - // here and hand a stale row to scrollToRow. - #expect(controller.resolvedScrollRowForTests(id: ghost.id) == nil) - - // An applied id must resolve to its applied-snapshot row, which stays in bounds. - // For the oldest item the old math would compute items.count - 1 - 0 = 10 — one - // past the applied snapshot's last row (9), the exact out-of-range index that - // aborted scrollToRow. The snapshot-derived row is 9. - let oldestPath = try #require(controller.resolvedScrollRowForTests(id: applied[0].id)) - #expect(oldestPath.row < controller.tableView.numberOfRows(inSection: 0)) - } - - @Test - func `Scroll completion reload does not re-enter diffable apply during animated updates`() async throws { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - - var items = (0..<120).map { index in - TestMessageItem(id: UUID(), text: "Message \(index)", revision: 0) - } - let targetIndex = 60 - let targetID = items[targetIndex].id - - controller.updateItems(items, animated: false) - try await waitForRowCount(items.count, in: controller, context: "initial seed") - - for iteration in 1...5 { - controller.scrollToItem(id: targetID, animated: true) - - var updatedItems = items - updatedItems[targetIndex] = TestMessageItem( - id: targetID, - text: "Message \(targetIndex) iteration \(iteration)", - revision: iteration - ) - updatedItems.append( - TestMessageItem( - id: UUID(), - text: "Appended \(iteration)", - revision: 0 - ) - ) - - controller.updateItems(updatedItems, animated: true) - controller.scrollViewDidEndScrollingAnimation(controller.tableView) - items = updatedItems - } - - try await waitForRowCount(items.count, in: controller, context: "final snapshot") - - #expect(controller.tableView.numberOfRows(inSection: 0) == items.count) - } - - @Test - func `A content-only reconfigure superseded mid-apply still reaches an applied snapshot`() async throws { - let controller = ChatTableViewController() - controller.configure { item in - Text(item.text) - } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let x = TestMessageItem(id: UUID(), text: "X revision 0", revision: 0) - let older = TestMessageItem(id: UUID(), text: "Older", revision: 0) - controller.updateItems([older, x], animated: false) - try await waitForRowCount(2, in: controller, context: "seed") - - // Open the coalescer window: mark an apply in flight so the next two updateItems - // calls park in the pending slot instead of applying immediately. - controller.beginApplyingForTests() - - // Content-only change to X parks reconfigureItems([x]) in the pending slot. - let xBumped = TestMessageItem(id: x.id, text: "X revision 1", revision: 1) - controller.updateItems([older, xBumped], animated: false) - - // Structural change (append Y) with X unchanged vs the parked snapshot. This - // supersedes the pending slot; the coalescer must carry X's targeted reconfigure - // forward rather than dropping it with latest-wins. - let y = TestMessageItem(id: UUID(), text: "Y", revision: 0) - controller.updateItems([older, xBumped, y], animated: false) - - // Drain applies the surviving snapshot synchronously (no window means non-animated). - controller.drainPendingForTests() - try await waitForRowCount(3, in: controller, context: "after drain") - - let reconfiguredX = controller.appliedReconfiguredItemIDsForTests.contains(x.id) - #expect( - reconfiguredX, - "X's content reconfigure must survive supersession and reach an applied snapshot" - ) - } - - // MARK: - Off-screen mention reporting - - /// Builds a controller with more rows than fit a 600pt viewport, so the flipped table rests at - /// the bottom with the newest item visible and the oldest off the top, and asserts that split so - /// every off-screen test shares the precondition: shifted row metrics fail here, not below. - private func makeOffscreenMentionController() throws - -> (controller: ChatTableViewController, items: [TestMessageItem]) { - let controller = ChatTableViewController() - controller.configure { Text($0.text) } - controller.loadViewIfNeeded() - controller.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 600) - - let items = (0..<50).map { TestMessageItem(id: UUID(), text: "Message \($0)", revision: 0) } - controller.updateItems(items, animated: false) - controller.tableView.layoutIfNeeded() - - // Read row visibility directly rather than driving onOffscreenMentionsChanged, so the - // precondition does not seed the report change-detection the tests rely on starting clean. - let visibleRows = Set(controller.tableView.indexPathsForVisibleRows ?? []) - let newestRow = try #require(controller.resolvedScrollRowForTests(id: items[items.count - 1].id)) - let oldestRow = try #require(controller.resolvedScrollRowForTests(id: items[0].id)) - #expect(visibleRows.contains(newestRow), "Precondition: newest item must be visible at rest") - #expect(!visibleRows.contains(oldestRow), "Precondition: oldest item must be off screen at rest") - - return (controller, items) - } - - @Test - func `A mention that is currently visible is not reported off screen`() throws { - let (controller, items) = try makeOffscreenMentionController() - - var reported: [UUID]? - controller.onOffscreenMentionsChanged = { reported = $0 } - controller.unseenMentionIDs = [items[items.count - 1].id] // newest, on screen - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - - #expect(reported == [], "A visible mention must not be reported off screen") - } - - @Test - func `A mention scrolled off screen is reported by id`() throws { - let (controller, items) = try makeOffscreenMentionController() - - var reported: [UUID]? - controller.onOffscreenMentionsChanged = { reported = $0 } - controller.unseenMentionIDs = [items[0].id] // oldest, above the viewport - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - - #expect(reported == [items[0].id], "A mention above the viewport must be reported off screen") - } - - @Test - func `Only the off-screen mentions are reported, in order`() throws { - let (controller, items) = try makeOffscreenMentionController() - - var reported: [UUID]? - controller.onOffscreenMentionsChanged = { reported = $0 } - controller.unseenMentionIDs = [items[0].id, items[items.count - 1].id] - - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - - // The visible newest is excluded; the off-screen oldest is the scroll target the button taps. - #expect(reported == [items[0].id], "Only the off-screen mention is reported") - } - - @Test - func `Off-screen report empties when the unseen set empties`() throws { - let (controller, items) = try makeOffscreenMentionController() - - var reported: [UUID]? - controller.onOffscreenMentionsChanged = { reported = $0 } - controller.unseenMentionIDs = [items[0].id] - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(reported == [items[0].id]) - - controller.unseenMentionIDs = [] - controller.scrollViewDidScroll(controller.tableView) - controller.flushScrollObservationsForTests() - #expect(reported == [], "Clearing the unseen set must hide the @ button") - } - - @Test - func `The settle-delay recheck reports an off-screen mention on load`() async throws { - let (controller, items) = try makeOffscreenMentionController() - - var reported: [UUID]? - controller.onOffscreenMentionsChanged = { reported = $0 } - controller.unseenMentionIDs = [items[0].id] // oldest, above the viewport - - // Exercise the layout-settle async path that actually fires on first load, not the - // synchronous scroll drain the other tests use. - controller.scheduleVisibleMentionsRecheck() - - try await waitUntil("settle-delay recheck should report the off-screen mention") { - reported != nil - } - - #expect(reported == [items[0].id], "The settle-delay recheck must report the off-screen mention") - } -} diff --git a/MC1Tests/Views/Chats/Components/MessageBubbleViewTests.swift b/MC1Tests/Views/Chats/Components/MessageBubbleViewTests.swift index ab869b59..b8f6a1b6 100644 --- a/MC1Tests/Views/Chats/Components/MessageBubbleViewTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageBubbleViewTests.swift @@ -17,9 +17,9 @@ struct MessageBubbleViewTests { func `DM bubble path: viewModel resolves the message for its stored item`() async throws { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage(text: "hello dm") - coordinator.replaceAll([message]) + coordinator.replaceAllForTesting([message]) viewModel.buildItems() await coordinator.buildItemsTask?.value @@ -36,14 +36,14 @@ struct MessageBubbleViewTests { func `Retry/failed bubble: envelope and footer capture retry state`() async throws { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator + viewModel.bindCoordinatorForTesting(coordinator) let message = makeMessage( text: "retry me", status: .failed, retryAttempt: 2, maxRetryAttempts: 3 ) - coordinator.replaceAll([message]) + coordinator.replaceAllForTesting([message]) viewModel.buildItems() await coordinator.buildItemsTask?.value diff --git a/MC1Tests/Views/Chats/Components/BubbleStatusRowTests.swift b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift similarity index 81% rename from MC1Tests/Views/Chats/Components/BubbleStatusRowTests.swift rename to MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift index 5cf55e3d..de73f285 100644 --- a/MC1Tests/Views/Chats/Components/BubbleStatusRowTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift @@ -3,9 +3,8 @@ import Foundation @testable import MC1Services import Testing -@Suite("BubbleStatusRow status text") -@MainActor -struct BubbleStatusRowTests { +@Suite("Message status text") +struct MessageStatusTextTests { private func makeItem( status: MessageStatus, isChannelMessage: Bool, @@ -55,26 +54,26 @@ struct BubbleStatusRowTests { /// in-progress so the user never sees a settled "Sent" that later fails. @Test func `DM .sent renders as Sending`() { - let text = BubbleStatusRow.statusText(for: makeItem(status: .sent, isChannelMessage: false)) + let text = MessageStatusText.text(for: makeItem(status: .sent, isChannelMessage: false).footer) #expect(text == L10n.Chats.Chats.Message.Status.sending) } /// A channel broadcast has no ACK, so `.sent` is its terminal success state. @Test func `Channel .sent renders as Sent`() { - let text = BubbleStatusRow.statusText(for: makeItem(status: .sent, isChannelMessage: true)) + let text = MessageStatusText.text(for: makeItem(status: .sent, isChannelMessage: true).footer) #expect(text == L10n.Chats.Chats.Message.Status.sent) } @Test func `DM .delivered still renders as Delivered`() { - let text = BubbleStatusRow.statusText(for: makeItem(status: .delivered, isChannelMessage: false)) + let text = MessageStatusText.text(for: makeItem(status: .delivered, isChannelMessage: false).footer) #expect(text == L10n.Chats.Chats.Message.Status.delivered) } @Test func `DM .failed still renders as Failed`() { - let text = BubbleStatusRow.statusText(for: makeItem(status: .failed, isChannelMessage: false)) + let text = MessageStatusText.text(for: makeItem(status: .failed, isChannelMessage: false).footer) #expect(text == L10n.Chats.Chats.Message.Status.failed) } } diff --git a/MC1Tests/Views/Chats/Components/TiledViewInitialScrollTargetTests.swift b/MC1Tests/Views/Chats/Components/TiledViewInitialScrollTargetTests.swift new file mode 100644 index 00000000..793cf0d7 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/TiledViewInitialScrollTargetTests.swift @@ -0,0 +1,137 @@ +import MessagingUI +import SwiftUI +import Testing +import UIKit + +/// Exercises the library's initial scroll target against a live layout pass. +/// The tiled layout positions content inside a large virtual coordinate space +/// via content insets, so anchor math validated only against item frames in +/// isolation can still resolve to a clamped bottom offset; these tests mount a +/// real hierarchy and assert the on-screen result. +@Suite("TiledView initial scroll target", .serialized) +@MainActor +struct TiledViewInitialScrollTargetTests { + private struct Row: Identifiable, Hashable { + let id: UUID + let index: Int + } + + private struct RowCell: TiledCellContent { + let item: Row + + func body(context: CellContext) -> some View { + Color.blue.frame(height: Harness.rowHeight) + } + } + + private struct Harness: View { + let rows: [Row] + let targetID: UUID? + @State private var position = TiledScrollPosition( + autoScrollsToBottomOnAppend: false, + scrollsToBottomOnReplace: true + ) + + var body: some View { + TiledView(items: rows, scrollPosition: $position) { row in + RowCell(item: row) + } + .initialScrollTarget(id: targetID.map { AnyHashable($0) }, anchor: .top) + .ignoresSafeArea() + } + + static let rowHeight: CGFloat = 44 + } + + private static let viewportHeight: CGFloat = 600 + + private func makeRows(count: Int) -> [Row] { + (0.. (UIWindow, UIHostingController) { + let controller = UIHostingController(rootView: Harness(rows: rows, targetID: targetID)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: Self.viewportHeight)) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + return (window, controller) + } + + private func waitForCollectionView( + in window: UIWindow, + itemCount: Int, + timeout: TimeInterval = 5 + ) -> (collectionView: UICollectionView, messagesSection: Int)? { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + guard let collectionView = findCollectionView(in: window) else { continue } + for section in 0.. 0 { + return (collectionView, section) + } + } + } + return nil + } + + private func findCollectionView(in view: UIView) -> UICollectionView? { + if let collectionView = view as? UICollectionView { return collectionView } + for subview in view.subviews { + if let found = findCollectionView(in: subview) { return found } + } + return nil + } + + @Test + func `opens with the target row at the viewport top`() throws { + let rows = makeRows(count: 60) + let target = rows[20] + let (window, _) = mount(rows: rows, targetID: target.id) + defer { window.isHidden = true } + + let found = try #require(waitForCollectionView(in: window, itemCount: rows.count)) + let attributes = try #require(found.collectionView.layoutAttributesForItem( + at: IndexPath(item: target.index, section: found.messagesSection) + )) + + let screenY = attributes.frame.minY - found.collectionView.contentOffset.y + #expect(abs(screenY) < 1, + "the target row must rest at the viewport top, not \(screenY) points below it") + } + + @Test + func `opens at the bottom without a target`() throws { + let rows = makeRows(count: 60) + let (window, _) = mount(rows: rows, targetID: nil) + defer { window.isHidden = true } + + let found = try #require(waitForCollectionView(in: window, itemCount: rows.count)) + let attributes = try #require(found.collectionView.layoutAttributesForItem( + at: IndexPath(item: rows.count - 1, section: found.messagesSection) + )) + + let screenBottom = attributes.frame.maxY - found.collectionView.contentOffset.y + #expect(abs(screenBottom - found.collectionView.bounds.height) < 1, + "the last row must rest at the viewport bottom") + } + + @Test + func `a target close to the bottom clamps to the bottom edge`() throws { + let rows = makeRows(count: 60) + let target = rows[rows.count - 2] + let (window, _) = mount(rows: rows, targetID: target.id) + defer { window.isHidden = true } + + let found = try #require(waitForCollectionView(in: window, itemCount: rows.count)) + let attributes = try #require(found.collectionView.layoutAttributesForItem( + at: IndexPath(item: rows.count - 1, section: found.messagesSection) + )) + + let screenBottom = attributes.frame.maxY - found.collectionView.contentOffset.y + #expect(abs(screenBottom - found.collectionView.bounds.height) < 1, + "a near-bottom target must clamp so the list stays flush with the viewport bottom") + } +} diff --git a/MC1Tests/Views/Chats/Linkify/MessageBodyAttributeBridgingTests.swift b/MC1Tests/Views/Chats/Linkify/MessageBodyAttributeBridgingTests.swift deleted file mode 100644 index 8f2305ac..00000000 --- a/MC1Tests/Views/Chats/Linkify/MessageBodyAttributeBridgingTests.swift +++ /dev/null @@ -1,449 +0,0 @@ -@testable import MC1 -@testable import MC1Services -import SwiftUI -import Testing -import UIKit - -/// Proves the render-time UIKit string `MessageBodyTextView` derives from the single authored -/// SwiftUI `AttributedString` carries the attributes the renderer is responsible for overlaying or -/// resolving, in both a light and a dark/high-contrast trait collection. The SwiftUI-side -/// equivalence guard does not cover this; the renderer's user-visible correctness rests on this test -/// plus the manual matrix. -/// -/// Driven from the same corpus the equivalence test uses, so any SwiftUI run with a `.link` that -/// fails to bridge into the UIKit string fails here. -@Suite("MessageBody Attribute Bridging Tests") -@MainActor -struct MessageBodyAttributeBridgingTests { - // MARK: - Style inputs - - private static let gamut = IdentityGamut( - hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], - saturation: 0.45...0.70 - ) - private static let luminances: [Double] = [0.2, 0.8] - - private static let light = UITraitCollection { traits in - traits.userInterfaceStyle = .light - traits.accessibilityContrast = .normal - } - - private static let darkHighContrast = UITraitCollection { traits in - traits.userInterfaceStyle = .dark - traits.accessibilityContrast = .high - } - - /// The base text color slot for incoming bubbles, matching `BaseColorSlot.incoming.swiftUIColor`. - private static let incomingBaseColor: Color = .primary - - private func swiftUIString( - _ text: String, - isOutgoing: Bool = false, - currentUserName: String? = nil, - isHighContrast: Bool = false - ) -> AttributedString { - MessageText.buildFormattedText( - text: text, - isOutgoing: isOutgoing, - currentUserName: currentUserName, - isHighContrast: isHighContrast, - outgoingTextColor: .white, - hashtagColor: .blue, - identityGamut: Self.gamut, - identityBackgroundLuminances: Self.luminances - ).text - } - - private func bridged( - _ swiftUI: AttributedString, - baseColor: Color = MessageBodyAttributeBridgingTests.incomingBaseColor, - traitCollection: UITraitCollection - ) -> NSAttributedString { - MessageBodyTextView.makeAttributedText( - from: swiftUI, - baseColor: baseColor, - traitCollection: traitCollection - ) - } - - // MARK: - Shared assertions - - /// Every character carries `.font` of the body text style; runs whose SwiftUI source carried - /// `inlinePresentationIntent == .stronglyEmphasized` carry the bold symbolic trait, and no other - /// run does. - private func assertFonts(_ swiftUI: AttributedString, _ uikit: NSAttributedString, _ label: Comment) { - #expect(uikit.length == swiftUI.characters.count, label) - - let boldOffsets = boldCharacterOffsets(in: swiftUI) - let bodyMetrics = UIFontMetrics(forTextStyle: .body) - - uikit.enumerateAttribute(.font, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - let font = value as? UIFont - #expect(font != nil, "missing font at \(range) for \(label)") - guard let font else { return } - - // Same point size family as the scaled body style (proves `.body`, not a stray size). - let expectedBody = bodyMetrics.scaledFont(for: .preferredFont(forTextStyle: .body)) - #expect( - abs(font.pointSize - expectedBody.pointSize) < 0.01, - "non-body font size \(font.pointSize) at \(range) for \(label)" - ) - - let isBold = font.fontDescriptor.symbolicTraits.contains(.traitBold) - for offset in range.location..<(range.location + range.length) { - #expect(isBold == boldOffsets.contains(offset), "bold mismatch at \(offset) for \(label)") - } - } - } - - /// Every character carries a non-nil resolved `.foregroundColor`: no run is left to fall through - /// to the system placeholder grey. The `.primary` base slot's resolution to `UIColor.label` - /// specifically is proven by `incomingBaseResolvesToLabel`; a fully-styled message (mention- or - /// hashtag-only) legitimately has no base-colored run, and outgoing messages use white, so this - /// corpus-wide assertion checks only that every character is colored. - private func assertForegroundColors( - _ uikit: NSAttributedString, - traitCollection: UITraitCollection, - _ label: Comment - ) { - let placeholderGrey = UIColor.placeholderText.resolvedColor(with: traitCollection) - var coveredLength = 0 - - uikit.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - let color = value as? UIColor - #expect(color != nil, "missing foregroundColor at \(range) for \(label)") - #expect(color != placeholderGrey, "grey fallthrough at \(range) for \(label)") - if color != nil { - coveredLength += range.length - } - } - - #expect(coveredLength == uikit.length, "foregroundColor does not cover the whole string for \(label)") - } - - /// The set of `.link` values in the UIKit string matches the SwiftUI string URL-for-URL. - private func assertLinkParity(_ swiftUI: AttributedString, _ uikit: NSAttributedString, _ label: Comment) { - let swiftLinks = Set(swiftUI.runs.compactMap(\.link)) - var uikitLinks: Set = [] - uikit.enumerateAttribute(.link, in: NSRange(location: 0, length: uikit.length)) { value, _, _ in - if let url = value as? URL { - uikitLinks.insert(url) - } else if let string = value as? String, let url = URL(string: string) { - uikitLinks.insert(url) - } - } - #expect(uikitLinks == swiftLinks, "link parity mismatch for \(label): swiftUI=\(swiftLinks) uikit=\(uikitLinks)") - } - - /// The self-mention `.backgroundColor` carries onto exactly the characters the SwiftUI string - /// backgrounded, resolved to a non-nil `UIColor` for the trait collection. SwiftUI's - /// `.backgroundColor` does not survive the default bridge, so a regression that drops it leaves - /// the highlighted offsets uncovered and fails here. - private func assertSelfMentionBackground( - _ swiftUI: AttributedString, - _ uikit: NSAttributedString, - _ label: Comment - ) { - let backgroundOffsets = backgroundColorOffsets(in: swiftUI) - #expect(!backgroundOffsets.isEmpty, "expected a self-mention background run for \(label)") - - var uikitBackgrounded: Set = [] - uikit.enumerateAttribute(.backgroundColor, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - guard value is UIColor else { return } - for offset in range.location..<(range.location + range.length) { - uikitBackgrounded.insert(offset) - } - } - #expect(uikitBackgrounded == backgroundOffsets, "self-mention background mismatch for \(label)") - } - - /// Underline carries onto exactly the characters the SwiftUI string underlined. - private func assertUnderline(_ swiftUI: AttributedString, _ uikit: NSAttributedString, _ label: Comment) { - let underlinedOffsets = underlinedCharacterOffsets(in: swiftUI) - var uikitUnderlined: Set = [] - uikit.enumerateAttribute(.underlineStyle, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - guard let raw = value as? Int, raw != 0 else { return } - for offset in range.location..<(range.location + range.length) { - uikitUnderlined.insert(offset) - } - } - #expect(uikitUnderlined == underlinedOffsets, "underline mismatch for \(label)") - } - - /// Runs the full assertion set in one trait collection. - private func assertBridged( - _ text: String, - isOutgoing: Bool = false, - currentUserName: String? = nil, - traitCollection: UITraitCollection, - _ label: Comment - ) { - let swiftUI = swiftUIString(text, isOutgoing: isOutgoing, currentUserName: currentUserName) - let baseColor: Color = isOutgoing ? .white : Self.incomingBaseColor - let uikit = bridged(swiftUI, baseColor: baseColor, traitCollection: traitCollection) - - assertFonts(swiftUI, uikit, label) - assertForegroundColors(uikit, traitCollection: traitCollection, label) - assertLinkParity(swiftUI, uikit, label) - assertUnderline(swiftUI, uikit, label) - } - - /// Runs the full assertion set in both a light and a dark/high-contrast trait collection. - private func assertBridgedAllTraits( - _ text: String, - isOutgoing: Bool = false, - currentUserName: String? = nil, - _ label: Comment - ) { - assertBridged(text, isOutgoing: isOutgoing, currentUserName: currentUserName, traitCollection: Self.light, "\(label) [light]") - assertBridged(text, isOutgoing: isOutgoing, currentUserName: currentUserName, traitCollection: Self.darkHighContrast, "\(label) [dark-hc]") - } - - // MARK: - Corpus (mirrors MessageLinkifierEquivalenceTests) - - @Test - func `URL-only message bridges`() { - assertBridgedAllTraits("https://example.com", "url-only") - } - - @Test - func `Coordinate-only message bridges`() { - assertBridgedAllTraits("37.334900, -122.009020", "coordinate-only") - } - - @Test - func `Mention-only message bridges`() { - assertBridgedAllTraits("@[Alice]", "mention-only") - } - - @Test - func `Hashtag-only message bridges with bold`() { - assertBridgedAllTraits("#general", "hashtag-only") - } - - @Test - func `Outgoing hashtag message bridges`() { - assertBridgedAllTraits("Join #general now", isOutgoing: true, "outgoing-hashtag") - } - - @Test - func `Contact-share message bridges`() { - let token = Self.shareToken(name: "Alice") - assertBridgedAllTraits("Add \(token) please", "contact-share") - } - - @Test - func `MeshCore contact link message bridges`() { - assertBridgedAllTraits("Open meshcore://contact/add?name=Bob now", "meshcore-link") - } - - @Test - func `Mixed mention, url, hashtag, coordinate bridges`() { - assertBridgedAllTraits("@[Bob] see https://a.com #ops at 37.7749, -122.4194", "mixed") - } - - @Test - func `Self-mention message bridges`() { - assertBridgedAllTraits("Hey @[Me] there", currentUserName: "Me", "self-mention") - } - - @Test - func `Outgoing self-mention message bridges`() { - assertBridgedAllTraits("Hey @[Me] there", isOutgoing: true, currentUserName: "Me", "outgoing-self-mention") - } - - @Test - func `RTL text with a URL bridges`() { - assertBridgedAllTraits("مرحبا https://example.com شكرا", "rtl") - } - - @Test - func `Comma-decimal locale coordinate text bridges`() { - assertBridgedAllTraits("Meet 48.858400, 2.294500 ok", "comma-decimal") - } - - @Test - func `Plain text with no links bridges`() { - assertBridgedAllTraits("Just a normal sentence.", "plain") - } - - // MARK: - Focused assertions - - @Test - func `Incoming base color resolves to UIColor.label, not grey`() { - let swiftUI = swiftUIString("Just a normal sentence.") - for traits in [Self.light, Self.darkHighContrast] { - let uikit = bridged(swiftUI, baseColor: Self.incomingBaseColor, traitCollection: traits) - let color = uikit.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor - #expect(color == UIColor.label.resolvedColor(with: traits)) - // UIColor.label is never the placeholder grey; this guards against a `.primary` that - // failed to bridge and fell through. - #expect(color != UIColor.placeholderText.resolvedColor(with: traits)) - } - } - - @Test - func `Self-mention background highlight bridges in light and dark/high-contrast`() { - let swiftUI = swiftUIString("Hey @[Me] there", currentUserName: "Me") - for traits in [Self.light, Self.darkHighContrast] { - let uikit = bridged(swiftUI, traitCollection: traits) - assertSelfMentionBackground(swiftUI, uikit, "self-mention-background") - } - } - - @Test - func `Hashtag bold run is derived from the body text style, not a fixed size`() { - let swiftUI = swiftUIString("ping #news end") - let uikit = bridged(swiftUI, traitCollection: Self.light) - - let bold = boldCharacterOffsets(in: swiftUI) - #expect(!bold.isEmpty, "expected a bold hashtag run") - - // A bold run built from the scaled `.body` text style shares the body point size; a fixed-size - // bold font would not rescale with Dynamic Type and would drift from this expected value. - let expectedBody = UIFontMetrics(forTextStyle: .body).scaledFont(for: .preferredFont(forTextStyle: .body)) - uikit.enumerateAttribute(.font, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - guard let font = value as? UIFont, - font.fontDescriptor.symbolicTraits.contains(.traitBold) else { return } - #expect( - abs(font.pointSize - expectedBody.pointSize) < 0.01, - "bold run not derived from body text style at \(range): \(font.pointSize) vs \(expectedBody.pointSize)" - ) - } - } - - @Test - func `Hashtag run is the only bold run and carries a link`() { - let swiftUI = swiftUIString("ping #news end") - let uikit = bridged(swiftUI, traitCollection: Self.light) - - let bold = boldCharacterOffsets(in: swiftUI) - #expect(!bold.isEmpty, "expected a bold hashtag run") - - uikit.enumerateAttribute(.font, in: NSRange(location: 0, length: uikit.length)) { value, range, _ in - guard let font = value as? UIFont else { return } - let isBold = font.fontDescriptor.symbolicTraits.contains(.traitBold) - for offset in range.location..<(range.location + range.length) { - #expect(isBold == bold.contains(offset)) - } - } - assertLinkParity(swiftUI, uikit, "hashtag-bold-link") - } - - // MARK: - Passive renderer policy - - /// The view empties `linkTextAttributes` so `.link` runs keep their authored `.foregroundColor` - /// instead of the view's `tintColor`. The bridging assertions above pass even with this missing - /// (they inspect the `NSAttributedString`, not the live view), so this is the bug-5 guard. - @Test - func `configureForBubble empties linkTextAttributes`() { - let textView = BubbleBodyTextView(usingTextLayoutManager: false) - textView.configureForBubble() - #expect(textView.linkTextAttributes.isEmpty) - } - - /// A tap on a linked glyph resolves the URL; a tap on non-link text, or in the trailing slack - /// past the end of the line, resolves nil rather than over-hitting the nearest glyph. - @Test - func `linkURL(at:) resolves linked glyphs and rejects non-link and trailing taps`() throws { - let url = try #require(URL(string: "https://example.com")) - let textView = makeLaidOutTextView("tap https://example.com end", linkURL: url, linkSubstring: "https://example.com") - - let linkRect = glyphRect(in: textView, of: "https://example.com") - #expect(textView.linkURL(at: CGPoint(x: linkRect.midX, y: linkRect.midY)) == url) - - let leadingRect = glyphRect(in: textView, of: "tap") - #expect(textView.linkURL(at: CGPoint(x: leadingRect.midX, y: leadingRect.midY)) == nil) - - #expect(textView.linkURL(at: CGPoint(x: 990, y: linkRect.midY)) == nil) - } - - /// A link at the very end of the line is not opened by a tap in the empty space to its right. - @Test - func `linkURL(at:) does not over-hit a trailing link from a tap past it`() throws { - let url = try #require(URL(string: "https://example.com")) - let textView = makeLaidOutTextView("see https://example.com", linkURL: url, linkSubstring: "https://example.com") - - let linkRect = glyphRect(in: textView, of: "https://example.com") - #expect(textView.linkURL(at: CGPoint(x: linkRect.midX, y: linkRect.midY)) == url) - #expect(textView.linkURL(at: CGPoint(x: 995, y: linkRect.midY)) == nil) - } - - private func makeLaidOutTextView( - _ text: String, - linkURL: URL, - linkSubstring: String - ) -> BubbleBodyTextView { - let textView = BubbleBodyTextView(usingTextLayoutManager: false) - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - textView.frame = CGRect(x: 0, y: 0, width: 1000, height: 200) - - let attributed = NSMutableAttributedString(string: text) - attributed.addAttribute( - .font, - value: UIFont.preferredFont(forTextStyle: .body), - range: NSRange(location: 0, length: attributed.length) - ) - attributed.addAttribute(.link, value: linkURL, range: (text as NSString).range(of: linkSubstring)) - textView.attributedText = attributed - textView.layoutManager.ensureLayout(for: textView.textContainer) - return textView - } - - private func glyphRect(in textView: BubbleBodyTextView, of substring: String) -> CGRect { - let characterRange = ((textView.text ?? "") as NSString).range(of: substring) - let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil) - return textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer) - } - - // MARK: - Offset helpers - - private func boldCharacterOffsets(in attributedString: AttributedString) -> Set { - characterOffsets(in: attributedString) { - $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true - } - } - - private func underlinedCharacterOffsets(in attributedString: AttributedString) -> Set { - characterOffsets(in: attributedString) { - $0.underlineStyle != nil - } - } - - private func backgroundColorOffsets(in attributedString: AttributedString) -> Set { - characterOffsets(in: attributedString) { - $0.backgroundColor != nil - } - } - - private func characterOffsets( - in attributedString: AttributedString, - where predicate: (AttributedString.Runs.Run) -> Bool - ) -> Set { - var offsets: Set = [] - var cursor = 0 - for run in attributedString.runs { - let length = attributedString.characters.distance( - from: run.range.lowerBound, - to: run.range.upperBound - ) - if predicate(run) { - for offset in cursor..<(cursor + length) { - offsets.insert(offset) - } - } - cursor += length - } - return offsets - } - - // MARK: - Helpers - - private static func shareToken(name: String) -> String { - guard let key = Data(hexString: String(repeating: "AB", count: 32)) else { - return "" - } - return ContactShareUtilities.formatShare(publicKey: key, type: .chat, name: name) - } -} diff --git a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift index 31d612ed..7e84cc89 100644 --- a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift +++ b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift @@ -78,7 +78,7 @@ struct ChatRenderStateTests { @Test func `updatingItem preserves phase`() async { let (viewModel, coordinator) = await makeChatSetup(messageCount: 3) - coordinator.markLoaded() + coordinator.markLoadedForTesting() viewModel.buildItems() await coordinator.buildItemsTask?.value @@ -93,7 +93,7 @@ struct ChatRenderStateTests { func `removingItem preserves phase`() async { let messages = (0..<2).map { makeMessage(index: $0) } let (viewModel, coordinator) = await makeChatSetup(messages: messages) - coordinator.markLoaded() + coordinator.markLoadedForTesting() viewModel.buildItems() await coordinator.buildItemsTask?.value @@ -185,6 +185,28 @@ struct ChatRenderStateTests { #expect(before == after) } + @Test + func `newMessagesDividerItemID resolves to the first divider-flagged item`() { + let plain = makeFakeMessageItem(id: UUID(), senderName: "a") + let flagged = makeFakeMessageItem(id: UUID(), senderName: "b", showNewMessagesDivider: true) + let trailing = makeFakeMessageItem(id: UUID(), senderName: "c") + let state = ChatRenderState.empty + .appendingItem(plain) + .appendingItem(flagged) + .appendingItem(trailing) + + #expect(state.newMessagesDividerItemID == flagged.id) + } + + @Test + func `newMessagesDividerItemID is nil when no item carries the divider`() { + let state = ChatRenderState.empty + .appendingItem(makeFakeMessageItem(id: UUID(), senderName: "a")) + .appendingItem(makeFakeMessageItem(id: UUID(), senderName: "b")) + + #expect(state.newMessagesDividerItemID == nil) + } + @Test func `appendingItem appends and updates totalFetchedCount`() { let initial = ChatRenderState.empty @@ -207,15 +229,19 @@ private func makeChatSetup(messageCount: Int) async -> (ChatViewModel, ChatCoord private func makeChatSetup(messages: [MessageDTO]) async -> (ChatViewModel, ChatCoordinator) { let viewModel = ChatViewModel() let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - coordinator.replaceAll(messages) + viewModel.bindCoordinatorForTesting(coordinator) + coordinator.replaceAllForTesting(messages) viewModel.buildItems() await coordinator.buildItemsTask?.value return (viewModel, coordinator) } @MainActor -private func makeFakeMessageItem(id: UUID, senderName: String) -> MessageItem { +private func makeFakeMessageItem( + id: UUID, + senderName: String, + showNewMessagesDivider: Bool = false +) -> MessageItem { MessageItem( id: id, envelope: MessageEnvelope( @@ -249,7 +275,7 @@ private func makeFakeMessageItem(id: UUID, senderName: String) -> MessageItem { showTimestamp: false, showDirectionGap: false, showSenderName: false, - showNewMessagesDivider: false + showNewMessagesDivider: showNewMessagesDivider ), shouldRequestPreviewFetch: false ) diff --git a/MC1Tests/Views/Chats/Models/MessageFootprintHashTests.swift b/MC1Tests/Views/Chats/Models/MessageFootprintHashTests.swift index 6aa1354e..2b1d13c9 100644 --- a/MC1Tests/Views/Chats/Models/MessageFootprintHashTests.swift +++ b/MC1Tests/Views/Chats/Models/MessageFootprintHashTests.swift @@ -23,7 +23,7 @@ struct MessageFootprintHashTests { let bumpedItem = MessageFragmentBuilder.makeItem(for: bumped, inputs: inputs, envInputs: envInputs) #expect(baseItem != bumpedItem, - "sendCount must be encoded into MessageItem; otherwise BubbleStatusRow can't refresh after channel resend") + "sendCount must be encoded into MessageItem; otherwise the status footer can't refresh after channel resend") #expect(baseItem.hashValue != bumpedItem.hashValue) } diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift index f0ff96ad..c4064bab 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift @@ -352,35 +352,36 @@ struct MessageFragmentBuilderTests { // MARK: - Incoming send time @Test - func `footer shows send time on incoming message when enabled`() { + func `footer shows send time on incoming message`() { let wire: UInt32 = 1_700_000_000 let message = makeIncomingMessage(timestamp: wire) let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( - for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: true) + for: message, inputs: inputs, envInputs: makeEnvInputs() ) #expect(item.footer.sendTimeToShow == Date(timeIntervalSince1970: TimeInterval(wire))) #expect(item.footer.sendTimeWasCorrected == false) } @Test - func `footer hides send time when the toggle is off`() { - let message = makeIncomingMessage(timestamp: 1_700_000_000) + func `footer shows send time regardless of the legacy showIncomingSendTime flag`() { + let wire: UInt32 = 1_700_000_000 + let message = makeIncomingMessage(timestamp: wire) let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: false) ) - #expect(item.footer.sendTimeToShow == nil) + #expect(item.footer.sendTimeToShow == Date(timeIntervalSince1970: TimeInterval(wire))) } @Test - func `footer hides send time on outgoing messages even when enabled`() { + func `footer shows send time on outgoing messages`() { let message = makeMessage(text: "hi") // outgoing by default let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( - for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: true) + for: message, inputs: inputs, envInputs: makeEnvInputs() ) - #expect(item.footer.sendTimeToShow == nil) + #expect(item.footer.sendTimeToShow == message.senderDate) } @Test diff --git a/project.yml b/project.yml index 53239ab9..97fd8d6d 100644 --- a/project.yml +++ b/project.yml @@ -28,6 +28,13 @@ packages: MapLibre: url: https://github.com/maplibre/maplibre-gl-native-distribution exactVersion: 6.25.1 + MessagingUI: + url: https://github.com/bwees-forks/swiftui-messaging-ui + # Pinned to an exact revision: Package.resolved is gitignored (the whole + # generated .xcodeproj is), so branch tracking resolves to whatever each + # machine last fetched and different developers silently build different + # library code. Bump this hash deliberately when the fork advances. + revision: eab33977a91dee56ba6c8103af0966de0148457d targets: MC1: @@ -43,6 +50,7 @@ targets: - package: MC1Services - package: Emojibase - package: MapLibre + - package: MessagingUI - target: MC1Widgets preBuildScripts: - name: SwiftGen