diff --git a/Sources/Shared/Models/ChatMessageEntrancePolicy.swift b/Sources/Shared/Models/ChatMessageEntrancePolicy.swift new file mode 100644 index 0000000..7c77fb3 --- /dev/null +++ b/Sources/Shared/Models/ChatMessageEntrancePolicy.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftUI + +/// Timing and geometry for the Messages-style chat animations. +/// +/// Two effects live here: the staggered rise of the first visible page when a +/// conversation opens, and the transition a freshly appended row uses while the +/// user already watches the timeline (an outgoing bubble launches from the +/// composer's send control, an incoming one springs from its own edge). +enum ChatMessageEntrancePolicy { + /// A row waits this long behind every newer row below it, so the visible + /// page unfolds bottom-up the way Messages does. + static let entranceStagger: TimeInterval = 0.035 + + /// Rows further from the bottom than this are left alone: past a screenful + /// the cascade is invisible and the row may only appear on a later scroll. + static let entranceCascadeLimit = 12 + + static let entranceRise: CGFloat = 16 + static let entranceScale: CGFloat = 0.94 + + static let sendScale: CGFloat = 0.2 + /// Launch point sits next to the composer's send control so the bubble + /// reads as thrown out of the field instead of fading in place. + static let sendRise: CGFloat = 12 + + static let receiveScale: CGFloat = 0.4 + static let receiveRise: CGFloat = 10 + + /// Upper bound of the spring used by both effects. The owning view keeps a + /// row's entrance state alive for this long so a recycled row that reappears + /// while scrolling plays nothing. + static let entranceAnimationDuration: TimeInterval = 0.75 + + /// A burst larger than this is a history page arriving, not a conversation: + /// animating every row of a page would flash the whole timeline. + static let appendAnimationLimit = 4 + + /// Total time the opening cascade can still be pending for the last row. + static var entranceCascadeDuration: TimeInterval { + TimeInterval(entranceCascadeLimit - 1) * entranceStagger + + entranceAnimationDuration + } + + static var appendAnimation: Animation { + .spring(response: 0.44, dampingFraction: 0.68) + } + + /// Per-row delays keyed by client ID. The newest row leads the cascade and + /// every older row follows one stagger step behind it; rows outside the + /// cascade get no delay and therefore no entrance at all. + static func entranceDelays(forIDs ids: [String]) -> [String: TimeInterval] { + let last = ids.count - 1 + guard last >= 0 else { return [:] } + let firstAnimated = max(0, ids.count - entranceCascadeLimit) + var delays: [String: TimeInterval] = [:] + delays.reserveCapacity(ids.count - firstAnimated) + for index in firstAnimated...last { + delays[ids[index]] = TimeInterval(last - index) * entranceStagger + } + return delays + } + + /// Rows a query refresh added below everything already on screen. + /// + /// A MAM page paged in above the viewport is deliberately ignored: replaying + /// the entrance mid-scroll would yank the timeline the user is reading. A + /// burst of rows is treated as the same kind of archive delivery. + static func appendedLaunchIDs( + in ids: [String], + after previous: Set + ) -> [String] { + guard !previous.isEmpty else { return [] } + guard let lastKnownIndex = ids.lastIndex(where: previous.contains) + else { return [] } + let appended = ids.dropFirst(lastKnownIndex + 1).filter { + !previous.contains($0) + } + guard appended.count <= appendAnimationLimit else { return [] } + return Array(appended) + } +} \ No newline at end of file diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index bfb52ed..fad96d9 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -75,6 +75,13 @@ struct ChatView: View { @State private var captureSuspendsArchiveSync = false @State private var pickerResetToken = UUID() @State private var timelineEntries: [ChatTimelineEntry] = [] + /// Messages-style entrance state: which rows currently animate, which IDs + /// the timeline already showed, and whether the opening cascade of this + /// conversation has played. + @State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:] + @State private var knownEntranceIDs: Set = [] + @State private var hasPlayedOpeningEntrance = false + @State private var entranceReleaseToken = UUID() /// Messages of this conversation straight from the SwiftData store, /// ordered like `AppModel.selectedMessages`: timestamp, then clientID. @@ -143,6 +150,13 @@ struct ChatView: View { _captureSuspendsArchiveSync = State(initialValue: false) _pickerResetToken = State(initialValue: UUID()) _timelineEntries = State<[ChatTimelineEntry]>(initialValue: []) + _entranceModes = State<[String: MessageBubbleEntrance.Mode]>( + initialValue: [:] + ) + _knownEntranceIDs = State>(initialValue: []) + _hasPlayedOpeningEntrance = State(initialValue: false) + _entranceConversationID = State(initialValue: nil) + _entranceReleaseToken = State(initialValue: UUID()) } var body: some View { @@ -376,6 +390,10 @@ struct ChatView: View { historyTopTriggerVisible = false historyAutoContinueCount = 0 emptyHistoryRetryCount = 0 + entranceModes = [:] + knownEntranceIDs = [] + hasPlayedOpeningEntrance = false + entranceReleaseToken = UUID() } .onDisappear { model.endConversationViewing(jid: conversation.jid) @@ -478,7 +496,87 @@ struct ChatView: View { private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) { - timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages) + let source = newMessages ?? messages + timelineEntries = ChatTimelineEntry.make(from: source) + updateEntranceModes(for: source) + } + + /// Decides which rows play a Messages-style entrance after a rebuild. + /// The first page of an opened conversation cascades bottom-up; rows that + /// appear later (a send, an incoming message) launch from the composer's + /// edge. Modes are released once the animation window closes so a row + /// recycled by the lazy stack during scrolling reappears statically. + private func updateEntranceModes(for newMessages: [ChatMessage]) { + let ids = newMessages.map(\.clientID) + let known = knownEntranceIDs + knownEntranceIDs = Set(ids) + + guard hasPlayedOpeningEntrance else { + guard !ids.isEmpty else { return } + hasPlayedOpeningEntrance = true + // The first message ever in an empty chat should launch out of + // the composer like any other send, not cascade in. + if ids.count == 1, known.isEmpty, + newMessages.first?.direction == .outgoing + { + applyLaunch(for: newMessages, ids: ids) + return + } + let delays = ChatMessageEntrancePolicy.entranceDelays( + forIDs: ids + ) + for (id, delay) in delays { + entranceModes[id] = .cascade(delay: delay) + } + scheduleEntranceRelease( + ids: Set(delays.keys), + after: ChatMessageEntrancePolicy.entranceCascadeDuration + ) + return + } + + applyLaunch( + for: newMessages, + ids: ChatMessageEntrancePolicy.appendedLaunchIDs( + in: ids, + after: known + ) + ) + } + + private func applyLaunch(for messages: [ChatMessage], ids: [String]) { + guard !ids.isEmpty else { return } + let launched = Set(ids) + var applied: Set = [] + for message in messages where launched.contains(message.clientID) { + entranceModes[message.clientID] = .launch( + outgoing: message.direction == .outgoing + ) + applied.insert(message.clientID) + } + scheduleEntranceRelease( + ids: applied, + after: ChatMessageEntrancePolicy.entranceAnimationDuration + ) + } + + private func scheduleEntranceRelease( + ids: Set, + after delay: TimeInterval + ) { + guard !ids.isEmpty else { return } + let token = entranceReleaseToken + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(delay * 1_000_000_000) + ) + guard !Task.isCancelled, token == entranceReleaseToken else { + return + } + for id in ids { + entranceModes[id] = nil + } + } } private var isSelectingMessages: Bool { @@ -606,6 +704,13 @@ struct ChatView: View { ) } ) + .modifier( + MessageBubbleEntrance( + mode: entranceModes[ + message.clientID + ] ?? .settled + ) + ) .id(message.clientID) } } diff --git a/Sources/Shared/UI/Components/MessageBubbleEntrance.swift b/Sources/Shared/UI/Components/MessageBubbleEntrance.swift new file mode 100644 index 0000000..e6b352e --- /dev/null +++ b/Sources/Shared/UI/Components/MessageBubbleEntrance.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Messages-style entrance for chat rows. +/// +/// Opening a conversation reveals its first page as a bottom-up cascade +/// (`.cascade`), and a row appended while the timeline is already rendered +/// springs out of the composer's send control when outgoing (`.launch`). +/// Everything else stays `.settled`, which keeps history scrolling, reactions +/// and selection updates free of animation. +struct MessageBubbleEntrance: ViewModifier { + enum Mode: Equatable { + case settled + case cascade(delay: TimeInterval) + case launch(outgoing: Bool) + + var isSettled: Bool { + if case .settled = self { return true } + return false + } + } + + let mode: Mode + + // A settled row must never pass through an invisible frame, so only the + // rows that actually animate start hidden. + @State private var isRevealed: Bool + @State private var hasStarted = false + + init(mode: Mode) { + self.mode = mode + _isRevealed = State(initialValue: mode.isSettled) + } + + func body(content: Content) -> some View { + content + .opacity(isRevealed ? 1 : 0) + .scaleEffect(isRevealed ? 1 : displacedScale, anchor: scaleAnchor) + .offset(y: isRevealed ? 0 : displacedRise) + .onAppear(perform: reveal) + } + + /// Outgoing bubbles shrink toward the trailing bottom corner, where the + /// composer's send control sits; incoming ones grow from their own edge. + private var displacedScale: CGFloat { + switch mode { + case .settled, .cascade: + return ChatMessageEntrancePolicy.entranceScale + case .launch(let outgoing): + return outgoing + ? ChatMessageEntrancePolicy.sendScale + : ChatMessageEntrancePolicy.receiveScale + } + } + + private var scaleAnchor: UnitPoint { + if case .launch(let outgoing) = mode, outgoing { return .bottomTrailing } + return .bottom + } + + private var displacedRise: CGFloat { + switch mode { + case .settled, .cascade: + return ChatMessageEntrancePolicy.entranceRise + case .launch(let outgoing): + return outgoing + ? ChatMessageEntrancePolicy.sendRise + : ChatMessageEntrancePolicy.receiveRise + } + } + + private func reveal() { + guard !hasStarted else { return } + hasStarted = true + guard !isRevealed else { return } + let delay: TimeInterval + if case .cascade(let cascadeDelay) = mode { + delay = cascadeDelay + } else { + delay = 0 + } + withAnimation( + ChatMessageEntrancePolicy.appendAnimation.delay(delay) + ) { + isRevealed = true + } + } +} diff --git a/t b/t new file mode 100644 index 0000000..fe8bb4c --- /dev/null +++ b/t @@ -0,0 +1,160 @@ + +Sources/Shared/UI/ChatView.swift +──────────────────────────────────────────────────────────────────────────────────────────── + +────────────────────────────┐ +75: struct ChatView: View { │ +────────────────────────────┘ + @State private var captureSuspendsArchiveSync = false + @State private var pickerResetToken = UUID() + @State private var timelineEntries: [ChatTimelineEntry] = [] + /// Messages-style entrance state: which rows currently animate, which IDs + /// the timeline already showed, and whether the opening cascade of this + /// conversation has played. + @State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:] + @State private var knownEntranceIDs: Set<String> = [] + @State private var hasPlayedOpeningEntrance = false + @State private var entranceReleaseToken = UUID() + + /// Messages of this conversation straight from the SwiftData store, + /// ordered like `AppModel.selectedMessages`: timestamp, then clientID. + +─────────────────────────────┐ +150: struct ChatView: View { │ +─────────────────────────────┘ + _captureSuspendsArchiveSync = State(initialValue: false) + _pickerResetToken = State(initialValue: UUID()) + _timelineEntries = State<[ChatTimelineEntry]>(initialValue: []) + _entranceModes = State<[String: MessageBubbleEntrance.Mode]>( + initialValue: [:] + ) + _knownEntranceIDs = State<Set<String>>(initialValue: []) + _hasPlayedOpeningEntrance = State(initialValue: false) + _entranceConversationID = State<String?>(initialValue: nil) + _entranceReleaseToken = State(initialValue: UUID()) + } + + var body: some View { + +─────────────────────────────┐ +390: struct ChatView: View { │ +─────────────────────────────┘ + historyTopTriggerVisible = false + historyAutoContinueCount = 0 + emptyHistoryRetryCount = 0 + entranceModes = [:] + knownEntranceIDs = [] + hasPlayedOpeningEntrance = false + entranceReleaseToken = UUID() + } + .onDisappear { + model.endConversationViewing(jid: conversation.jid) + +─────────────────────────────┐ +496: struct ChatView: View { │ +─────────────────────────────┘ + + private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) + { + timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages) + let source = newMessages ?? messages + timelineEntries = ChatTimelineEntry.make(from: source) + updateEntranceModes(for: source) + } + + /// Decides which rows play a Messages-style entrance after a rebuild. + /// The first page of an opened conversation cascades bottom-up; rows that + /// appear later (a send, an incoming message) launch from the composer's + /// edge. Modes are released once the animation window closes so a row + /// recycled by the lazy stack during scrolling reappears statically. + private func updateEntranceModes(for newMessages: [ChatMessage]) { + let ids = newMessages.map(\.clientID) + let known = knownEntranceIDs + knownEntranceIDs = Set(ids) + + guard hasPlayedOpeningEntrance else { + guard !ids.isEmpty else { return } + hasPlayedOpeningEntrance = true + // The first message ever in an empty chat should launch out of + // the composer like any other send, not cascade in. + if ids.count == 1, known.isEmpty, + newMessages.first?.direction == .outgoing + { + applyLaunch(for: newMessages, ids: ids) + return + } + let delays = ChatMessageEntrancePolicy.entranceDelays( + forIDs: ids + ) + for (id, delay) in delays { + entranceModes[id] = .cascade(delay: delay) + } + scheduleEntranceRelease( + ids: Set(delays.keys), + after: ChatMessageEntrancePolicy.entranceCascadeDuration + ) + return + } + + applyLaunch( + for: newMessages, + ids: ChatMessageEntrancePolicy.appendedLaunchIDs( + in: ids, + after: known + ) + ) + } + + private func applyLaunch(for messages: [ChatMessage], ids: [String]) { + guard !ids.isEmpty else { return } + let launched = Set(ids) + var applied: Set<String> = [] + for message in messages where launched.contains(message.clientID) { + entranceModes[message.clientID] = .launch( + outgoing: message.direction == .outgoing + ) + applied.insert(message.clientID) + } + scheduleEntranceRelease( + ids: applied, + after: ChatMessageEntrancePolicy.entranceAnimationDuration + ) + } + + private func scheduleEntranceRelease( + ids: Set<String>, + after delay: TimeInterval + ) { + guard !ids.isEmpty else { return } + let token = entranceReleaseToken + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(delay * 1_000_000_000) + ) + guard !Task.isCancelled, token == entranceReleaseToken else { + return + } + for id in ids { + entranceModes[id] = nil + } + } + } + + private var isSelectingMessages: Bool { + +─────────────────────────────┐ +704: struct ChatView: View { │ +─────────────────────────────┘ + ) + } + ) + .modifier( + MessageBubbleEntrance( + mode: entranceModes[ + message.clientID + ] ?? .settled + ) + ) + .id(message.clientID) + } + }