diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 92a77dd4bc..0e2c66dfaa 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -51,7 +51,7 @@ struct PinnedAvatar: Equatable { let member: GroupMember let showGroupAsSender: Bool let y: CGFloat - let rowId: Int64 + let rowId: Int64? } class PinnedAvatarModel: NSObject, ObservableObject, UIGestureRecognizerDelegate { @@ -78,8 +78,9 @@ class PinnedAvatarModel: NSObject, ObservableObject, UIGestureRecognizerDelegate func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard let pinned, let scrollView = gestureRecognizer.view as? EndlessScrollView, !scrollView.isDecelerating else { return false } let p = touch.location(in: scrollView) + let x = scrollView.effectiveUserInterfaceLayoutDirection == .rightToLeft ? scrollView.bounds.width - p.x : p.x let y = p.y - scrollView.contentOffset.y - scrollView.insetTop - return p.x >= 12 && p.x <= 12 + memberImageSize && y >= pinned.y && y <= pinned.y + memberImageSize + return x >= 12 && x <= 12 + memberImageSize && y >= pinned.y && y <= pinned.y + memberImageSize } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -1773,23 +1774,24 @@ struct ChatView: View { let listTop = scrollView.contentOffset.y + scrollView.insetTop let pinLine = listTop + 4 guard let atTop = visible.first(where: { $0.view.frame.minY <= pinLine }), atTop.index < items.count else { return nil } + let lowest = visible[0].index + let highest = min(visible[visible.count - 1].index, items.count - 1) var last = atTop.index - while last > 0 && continuesSenderRun(items[last - 1]) { + while last >= lowest && last > 0 && continuesSenderRun(items[last - 1]) { last -= 1 } let runBottom = visible.first(where: { $0.index == last })?.view.frame.maxY ?? .infinity let pinnedTop = min(pinLine, runBottom - memberImageSize) - var first = atTop.index - while first < items.count && continuesSenderRun(items[first]) { - first += 1 + var top = atTop.index + while top < highest && continuesSenderRun(items[top]) { + top += 1 } - guard first < items.count else { return nil } - let rowId = items[first].oldest().item.id - if let firstRow = visible.first(where: { $0.index == first }), + let rowId: Int64? = continuesSenderRun(items[top]) ? nil : items[top].oldest().item.id + if let rowId, let firstRow = visible.first(where: { $0.index == top }), firstRow.view.frame.minY + (pinnedAvatarModel.memberImageTops[rowId] ?? 0) >= pinnedTop { return nil } - let shownItem = items[first].newest().item + let shownItem = items[top].newest().item guard case let .groupRcv(member) = shownItem.chatDir else { return nil } return PinnedAvatar(member: member, showGroupAsSender: shownItem.meta.showGroupAsSender, y: pinnedTop - listTop, rowId: rowId) } @@ -1823,7 +1825,8 @@ struct ChatView: View { MemberProfileImage(pinned.member, size: memberImageSize, backgroundColor: theme.colors.background) } } - .offset(x: 12 + (selecting ? 12 + 24 : 0), y: pinned.y) + .padding(.leading, 12 + (selecting ? 12 + 24 : 0)) + .offset(y: pinned.y) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .clipped() .allowsHitTesting(false) diff --git a/plans/2026-10-02-sticky-sender-avatar.md b/plans/2026-10-02-sticky-sender-avatar.md index 9aed116506..a4d2e71332 100644 --- a/plans/2026-10-02-sticky-sender-avatar.md +++ b/plans/2026-10-02-sticky-sender-avatar.md @@ -33,7 +33,11 @@ Rejected: - Rows are SwiftUI views inside UIKit cells positioned by `EndlessScrollView`, so a per-frame offset inside a cell would trail the cell by a frame and jitter. Instead an overlay in `ChatView` draws the pinned avatar, and the first row's own avatar is hidden (opacity) while it is pinned. - The pin is computed in the existing scroll listener (`onChatItemsUpdated`, called from `layoutSubviews` on every scroll frame) and published only when it changes. Rows observe only the hidden-row id, so pushing the avatar up does not re-render every visible row. - Rows report where their avatar is inside the row (named coordinate space), and the pin is recomputed when that changes. +- Run geometry walks only the visible rows. While the run's first row is above them, the overlay shows the member of the topmost visible message of the run, as on Android/desktop. +- **One vertical reference.** `chatItemsList` extends the scroll view 100pt above its `GeometryReader` (`.padding(.vertical, -100)`), and `EndlessScrollView.insetTop` is the same 100pt (never reassigned), so `contentOffset.y + insetTop` is the top of the `GeometryReader`. That `GeometryReader` and the overlay both fill the `ZStack` that is the first child of the chat `VStack`, whose top is the bottom of the navigation bar plus the `safeAreaInset(edge: .top)` search bar and divider. The pin line, the overlay's `y` and the tap test all measure from that top, on every device and with search open or closed. The iOS chat view has no one-hand (bottom toolbar) layout. +- **Right-to-left.** The row places its avatar with `.padding(.leading, 12)` (plus the selection checkbox), so it sits at the right edge in RTL. The overlay uses the same `.padding(.leading, …)` with `.topLeading` alignment, and the tap test measures x from the right edge of the scroll view when its `effectiveUserInterfaceLayoutDirection` is right-to-left. The iOS app has no RTL localization yet, so RTL is reachable only with the right-to-left pseudolanguage. - The overlay is above the scroll view, so it would swallow drags. It doesn't take touches; a tap recognizer on the scroll view begins only inside the pinned avatar, doesn't cancel other touches, and ignores a tap that stops a fling. +- This part has not been compiled or run: there is no Swift toolchain here, so it was checked by reading the code only. ## Known limitations