Update tgwatch

This commit is contained in:
isaac 2026-05-31 18:13:57 +02:00
parent 750911ce3c
commit a3492c0cb0

View file

@ -26,6 +26,11 @@ struct MessageListView: View {
// (unreads present false, user is parked at the divider, not the bottom).
@State private var isAtBottom: Bool
@State private var didApplyInitialScroll: Bool = false
// Live scroll-view content height, updated by a dedicated geometry observer
// (see `.onScrollGeometryChange(for: CGFloat.self)` below). The initial-
// position `.task` polls this to detect when bubbles have finished async-
// sizing, rather than guessing a fixed settle delay.
@State private var observedContentHeight: CGFloat = 0
// Pagination triggers fire from `.onScrollVisibilityChange` on the top/bottom rows.
// On initial layout especially for all-unread chats where the divider lands at
// row index 0 the topmost rows are visible without any user gesture, which would
@ -144,9 +149,9 @@ struct MessageListView: View {
}
.padding()
default:
// ScrollViewReader is hoisted ABOVE the VStack so the
// `.overlay(alignment: .bottomTrailing) { ... }` jump-to-bottom button
// can capture `proxy` and call `proxy.scrollTo(...)` after jumpToBottom.
// ScrollViewReader wraps the VStack so the initial-position `.task`,
// loadOlder scroll-preservation, and tail auto-scroll handlers can capture
// `proxy` and call `proxy.scrollTo(...)`.
ScrollViewReader { proxy in
VStack(spacing: 0) {
if let err = store.lastSendError {
@ -225,22 +230,26 @@ struct MessageListView: View {
Task { await store.sendText(snapshot) }
}
)
.id("composeAnchor")
.padding(.top, 8)
}
// 19pt bottom content inset so the ReplyBar's "+" and pill sit
// 19pt above the *physical* screen edge when the chat is scrolled
// to the tail. Combined with .ignoresSafeArea(edges: .bottom) on
// the ScrollView below without that, the system bottom safe-area
// adds extra space and the total inset overshoots. Carried as an
// id'd zero-content spacer (not `.padding(.bottom, 19)` on the
// VStack) so it's part of the scrollable content and
// `proxy.scrollTo("bottomAnchor", .bottom)` reaches the TRUE
// content bottom anchoring to the ReplyBar instead stops 19pt
// short, landing the chat just above the real tail.
Color.clear
.frame(height: 19)
.id("bottomAnchor")
}
.padding(.horizontal, 4)
.padding(.top, 4)
// 19pt bottom content inset so the ReplyBar's "+" and
// pill sit 19pt above the *physical* screen edge when
// the chat is scrolled to the tail. Combined with
// .ignoresSafeArea(edges: .bottom) on the ScrollView
// below without that, the system bottom safe-area
// adds extra space and the total inset overshoots.
.padding(.bottom, 19)
}
.ignoresSafeArea(edges: .bottom)
.defaultScrollAnchor(.bottom)
.scrollContentBackground(.hidden)
.background {
Image("ChatBG")
@ -277,26 +286,69 @@ struct MessageListView: View {
// pagination so it only fires after the user actually moved.
userHasScrolled = true
}
// Dedicated content-height tracker for the initial-position
// settle loop. The ScrollSnapshot observer above bails early on
// contentSize changes (to preserve user-driven isAtBottom), so it
// can't be used to watch growth; this one records height on every
// change, including the async bubble-sizing growth we wait out.
.onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height } action: { _, newValue in
observedContentHeight = newValue
}
.task {
// Initial positioning. This `default` branch only renders once the
// store is `.loaded` (the `.loadingFirstPage` case keeps the spinner
// up), so `store.rows` is fully populated true for BOTH the fast
// path (warmed before push) and the slow path (warm finished after
// push, swapping this branch in). For the unread case, scroll to the
// divider; the spinner cover (see `content`'s ZStack) hides the brief
// bottom-paintdivider settle so there's no visible jump. We use
// `proxy.scrollTo` rather than `scrollPosition(id:)` because the
// content is a plain (non-lazy) `VStack` `scrollPosition(id:)` only
// positions reliably inside lazy stacks, and switching to `LazyVStack`
// reintroduces the `defaultScrollAnchor` over-scroll gotcha.
// push, swapping this branch in).
//
// We drive BOTH the unread ( divider, `.top`) and the read ( tail,
// `.bottom`) cases imperatively. We do NOT use
// `.defaultScrollAnchor(.bottom)`: on a non-lazy `VStack` it latches
// the bottom offset at the first, too-short layout and reverts to that
// stale offset as content grows landing ~1 screen above the true
// tail. We use `proxy.scrollTo` rather than `scrollPosition(id:)`
// because the content is a plain (non-lazy) `VStack`
// `scrollPosition(id:)` only positions reliably inside lazy stacks, and
// switching to `LazyVStack` reintroduces the over-scroll gotcha.
//
// The hard part is TIMING: bubble heights settle a few frames AFTER
// first layout media (photos, videos, maps, stickers) async-size well
// past any fixed delay. A guessed sleep fires the scroll while
// contentSize is still too short; as bubbles above the anchor then
// grow, `bottomAnchor` is pushed down and we're left parked ~1 screen
// above the tail (the original regression a fixed 50ms sleep was not
// enough for media-heavy chats). So instead of guessing, re-pin every
// frame until contentSize stops changing (stable for a few consecutive
// frames), capped so a never-settling chat still reveals. The `.overlay`
// cover (gated on `!didApplyInitialScroll`) hides the whole settle so
// there's no visible movement.
guard !didApplyInitialScroll else { return }
if let target = store.window.initialScrollTargetId {
// Let SwiftUI lay out the rows before resolving the target id.
try? await Task.sleep(nanoseconds: 50_000_000)
proxy.scrollTo(target, anchor: .top)
// One more runloop tick so the scroll offset lands before reveal.
await Task.yield()
let applyInitialPosition: @MainActor () -> Void = {
if let target = store.window.initialScrollTargetId {
proxy.scrollTo(target, anchor: .top)
} else {
proxy.scrollTo("bottomAnchor", anchor: .bottom)
}
}
var lastHeight: CGFloat = -1
var stableFrames = 0
for _ in 0..<90 { // ~1.5s cap at one frame each
applyInitialPosition()
try? await Task.sleep(nanoseconds: 16_000_000)
let height = observedContentHeight
if height == lastHeight {
stableFrames += 1
if stableFrames >= 3 { break } // settled for ~3 frames
} else {
stableFrames = 0
lastHeight = height
}
}
// Final pin once content has settled, plus one runloop pass to catch
// any last residual growth between the final sleep and reveal.
applyInitialPosition()
await Task.yield()
applyInitialPosition()
didApplyInitialScroll = true
}
.onChange(of: store.rows.first?.id) { oldId, newId in
@ -327,51 +379,19 @@ struct MessageListView: View {
case .bubble(let bubble) = store.rows.last else { return }
guard bubble.isOutgoing || isAtBottom else { return }
withAnimation(.easeOut(duration: 0.2)) {
if row.canSend {
proxy.scrollTo("composeAnchor", anchor: .bottom)
} else if let lastId = store.rows.last?.id {
proxy.scrollTo(lastId, anchor: .bottom)
}
proxy.scrollTo("bottomAnchor", anchor: .bottom)
}
}
}
.overlay(alignment: .bottomTrailing) {
// Hidden when at the bottom; visible when there's something newer
// (unseen incomings OR a non-tail-reaching window).
if !isAtBottom && (store.unseenNewerCount > 0 || !store.window.reachesChatTail) {
Button {
Task {
await store.jumpToBottom()
// jumpToBottom's slow path rebuilds the window in place without
// cycling loadState, so the .onChange(of: loadState) handler doesn't
// re-fire. We scroll directly here, which handles both paths:
// - Fast path (already at tail): rows unchanged, scroll to current last.
// - Slow path (window rebuilt): rows.last is the new chat tail after
// the await returns.
withAnimation(.easeOut(duration: 0.2)) {
if row.canSend {
proxy.scrollTo("composeAnchor", anchor: .bottom)
} else if let lastId = store.rows.last?.id {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
} label: {
JumpButtonGlyph(badgeCount: store.unseenNewerCount)
}
.buttonStyle(.plain)
.padding(.trailing, 6)
.padding(.bottom, 6)
.accessibilityIdentifier("jumpToBottom")
}
}
}
// Cover the brief bottom-paintdivider settle (the `.task` above scrolls to
// the unread divider ~50ms after this branch appears) so the user never sees
// the jump. Only for the unread case the tail case is already correctly
// placed by `.defaultScrollAnchor(.bottom)`, so it reveals immediately.
// Cover the first-layoutsettle window (the `.task` above re-pins to the final
// position until contentSize stabilizes) so the user never sees the jump.
// Covers BOTH cases: the unread divider (`.top`) and the read tail (`.bottom`)
// are both placed imperatively after the content settles, because there is no
// `.defaultScrollAnchor(.bottom)` to hold position while bubbles async-grow
// (it reverts to a stale first-layout offset see the `.task` comment).
.overlay {
if !didApplyInitialScroll, store.window.initialScrollTargetId != nil {
if !didApplyInitialScroll {
LoadingView(label: "Loading messages…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.ignoresSafeArea())
@ -380,34 +400,3 @@ struct MessageListView: View {
}
}
}
/// Visual glyph for the jump-to-bottom button. The real button at the
/// MessageListView overlay wraps this in a Button, and the #Preview blocks
/// below render it directly so layout changes are visible in snapshots.
private struct JumpButtonGlyph: View {
let badgeCount: Int
var body: some View {
ZStack(alignment: .topTrailing) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 28))
.foregroundStyle(.white, .blue)
if badgeCount > 0 {
Text("\(min(badgeCount, 99))")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Capsule().fill(.red))
.foregroundStyle(.white)
.offset(x: 6, y: -4)
}
}
}
}
#if DEBUG
#Preview("Jump no badge") { JumpButtonGlyph(badgeCount: 0).padding() }
#Preview("Jump badge 1") { JumpButtonGlyph(badgeCount: 1).padding() }
#Preview("Jump badge 12") { JumpButtonGlyph(badgeCount: 12).padding() }
#Preview("Jump badge 99+") { JumpButtonGlyph(badgeCount: 250).padding() }
#endif