Skip to content

Commit b70aa93

Browse files
committed
Surface threaded messages as regular replies in the timeline
Show thread replies inline in the main timeline instead of hiding them. The SDK's sendReply() automatically forwards m.thread relations, so thread continuity is preserved without any thread-specific UI. - Add threadRootEventID to TimelineMessage model for SDK propagation - Change hideThreadedEvents from true to false across all timeline modes - Extract threadRoot from MsgLikeContent in TimelineMessageMapper - Replace swipe-to-reply arrow with a lock-open action bar: short swipe locks the bar open, long swipe triggers reply directly - Extract highlightBadge into its own computed property on MessageView Assisted-By: Claude
1 parent 988ed54 commit b70aa93

6 files changed

Lines changed: 171 additions & 63 deletions

File tree

Packages/RelayInterface/Sources/RelayInterface/Models/TimelineMessage.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,12 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
278278
/// or error badge next to the message.
279279
public var sendState: SendState?
280280

281+
/// The event ID of the thread root, if this message belongs to a thread.
282+
///
283+
/// Present on thread replies. Used by the SDK to propagate `m.thread`
284+
/// relations when the user replies to a threaded message.
285+
public var threadRootEventID: String?
286+
281287
/// Creates a new ``TimelineMessage`` value.
282288
///
283289
/// - Parameters:
@@ -298,6 +304,7 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
298304
/// - replyDetail: Reply context, if this is a reply.
299305
/// - isEdited: Whether the message has been edited. Defaults to `false`.
300306
/// - sendState: The delivery state for outgoing messages. Defaults to `nil`.
307+
/// - threadRootEventID: The thread root event ID, if this is a thread reply.
301308
nonisolated public init(
302309
id: String,
303310
eventID: String? = nil,
@@ -314,7 +321,8 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
314321
isHighlighted: Bool = false,
315322
replyDetail: ReplyDetail? = nil,
316323
isEdited: Bool = false,
317-
sendState: SendState? = nil
324+
sendState: SendState? = nil,
325+
threadRootEventID: String? = nil
318326
) {
319327
self.id = id
320328
self.eventID = eventID ?? id
@@ -332,6 +340,7 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
332340
self.replyDetail = replyDetail
333341
self.isEdited = isEdited
334342
self.sendState = sendState
343+
self.threadRootEventID = threadRootEventID
335344
}
336345

337346
/// The best available display name for the sender, falling back to the Matrix user ID.

Relay/Views/Message/MessageView.swift

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,7 @@ struct MessageView: View { // swiftlint:disable:this type_body_length
9191
messageContent
9292
.overlay(alignment: .topTrailing) {
9393
if message.isHighlighted {
94-
Image(systemName: "at")
95-
.font(.system(size: 9, weight: .bold))
96-
.foregroundStyle(.white)
97-
.frame(width: 16, height: 16)
98-
.background(.red, in: Circle())
94+
highlightBadge
9995
.offset(x: 4, y: -4)
10096
}
10197
}
@@ -120,12 +116,6 @@ struct MessageView: View { // swiftlint:disable:this type_body_length
120116
showEmojiPicker = false
121117
}
122118
}
123-
.background(alignment: .leading) {
124-
if swipeOffset > 0 {
125-
replyArrow
126-
.offset(x: -swipeOffset)
127-
}
128-
}
129119
}
130120
}
131121
.frame(maxWidth: 500, alignment: message.isOutgoing ? .trailing : .leading)
@@ -396,17 +386,15 @@ struct MessageView: View { // swiftlint:disable:this type_body_length
396386

397387

398388

399-
// MARK: - Reply Arrow
389+
// MARK: - Message Badges
400390

401-
private var replyArrow: some View {
402-
let triggerThreshold: CGFloat = 80
403-
let progress = min(swipeOffset / triggerThreshold, 1.0)
404-
405-
return Image(systemName: "arrowshape.turn.up.left.fill")
406-
.font(.title)
407-
.foregroundStyle(.secondary)
408-
.scaleEffect(0.4 + 0.6 * progress)
409-
.opacity(Double(progress))
391+
/// A small badge indicating this message mentions the current user.
392+
private var highlightBadge: some View {
393+
Image(systemName: "at")
394+
.font(.system(size: 9, weight: .bold))
395+
.foregroundStyle(.white)
396+
.frame(width: 16, height: 16)
397+
.background(.red, in: Circle())
410398
}
411399

412400
// MARK: - Bubble Color
@@ -534,7 +522,7 @@ struct MessageView: View { // swiftlint:disable:this type_body_length
534522
timestamp: .now.addingTimeInterval(-20),
535523
isOutgoing: true,
536524
reactions: [],
537-
replyDetail: nil,
525+
replyDetail: nil
538526
),
539527
currentUserID: "@me:matrix.org"
540528
)

Relay/Views/Timeline/TimelineRowView.swift

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,23 @@ private struct SwipeOffsetKey: EnvironmentKey {
2121
static let defaultValue: CGFloat = 0
2222
}
2323

24+
private struct SwipeIsLockedKey: EnvironmentKey {
25+
static let defaultValue = false
26+
}
27+
2428
extension EnvironmentValues {
2529
/// The current horizontal swipe offset applied during a swipe-to-reply gesture.
2630
/// Child views can read this to render swipe-dependent UI (e.g. a reply arrow).
2731
var swipeOffset: CGFloat {
2832
get { self[SwipeOffsetKey.self] }
2933
set { self[SwipeOffsetKey.self] = newValue }
3034
}
35+
36+
/// Whether the swipe action bar is locked open and awaiting a button tap.
37+
var swipeIsLocked: Bool {
38+
get { self[SwipeIsLockedKey.self] }
39+
set { self[SwipeIsLockedKey.self] = newValue }
40+
}
3141
}
3242

3343
/// A single row in the timeline, rendering either a system event or a user message
@@ -86,13 +96,20 @@ struct TimelineRowView: View, Equatable {
8696
return swipeState.offset
8797
}
8898

99+
/// Whether the action bar on this row is locked open.
100+
private var isSwipeLocked: Bool {
101+
guard let swipeState, swipeState.swipingMessageId == row.message.id else { return false }
102+
return swipeState.isLocked
103+
}
104+
89105
/// Whether this row should animate in.
90106
private var shouldAnimate: Bool { isNewlyAppended && !didAppear }
91107

92108
var body: some View {
93109
rowContent
94110
.padding(.horizontal, 16)
95111
.environment(\.swipeOffset, currentSwipeOffset)
112+
.environment(\.swipeIsLocked, isSwipeLocked)
96113
.offset(x: currentSwipeOffset)
97114
.opacity(shouldAnimate ? 0 : 1)
98115
.animation(
@@ -106,6 +123,28 @@ struct TimelineRowView: View, Equatable {
106123
}
107124
}
108125

126+
// MARK: - Swipe Action Bar
127+
128+
/// Action bar with reply and thread buttons, slides in from the left
129+
/// while the row slides right. Rendered as a background so it stays
130+
/// stationary while the row content shifts.
131+
private var swipeActionBar: some View {
132+
// Scale the reply button when swiping past the lock threshold (100pt)
133+
// to hint that a long swipe (140pt+) triggers reply directly.
134+
let longSwipeProgress = max(0, min((currentSwipeOffset - 100) / 80, 1.0))
135+
let replyScale = 1.0 + longSwipeProgress * 0.8
136+
137+
return Button("Reply", systemImage: "arrowshape.turn.up.left.fill") {
138+
onReply(message)
139+
}
140+
.labelStyle(.iconOnly)
141+
.scaleEffect(replyScale)
142+
.font(.title3)
143+
.foregroundStyle(longSwipeProgress > 0 ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
144+
.buttonStyle(.plain)
145+
.allowsHitTesting(isSwipeLocked)
146+
}
147+
109148
@ViewBuilder
110149
private var rowContent: some View {
111150
if showUnreadMarker && message.id == firstUnreadMessageId {
@@ -134,25 +173,35 @@ struct TimelineRowView: View, Equatable {
134173
onHighlightDismissed()
135174
}
136175
} else {
137-
MessageView(
138-
message: message,
139-
isLastInGroup: info.isLastInGroup,
140-
showSenderName: info.showSenderName,
141-
onToggleReaction: { key in
142-
onToggleReaction(message.eventID, key)
143-
},
144-
onTapReply: { eventID in
145-
onTapReply(eventID)
146-
},
147-
onAvatarDoubleTap: {
148-
onAvatarDoubleTap(message)
149-
},
150-
onUserTap: { userId in
151-
onUserTap(userId)
152-
},
153-
onRoomTap: onRoomTap,
154-
currentUserID: currentUserID
155-
)
176+
ZStack(alignment: .leading) {
177+
// Action bar sits behind the message, revealed as the row slides right
178+
if currentSwipeOffset > 0 {
179+
swipeActionBar
180+
.opacity(min(currentSwipeOffset / 60, 1.0))
181+
.offset(x: message.isOutgoing ? 88 : -64,
182+
y: message.isOutgoing ? 0 : 8)
183+
}
184+
185+
MessageView(
186+
message: message,
187+
isLastInGroup: info.isLastInGroup,
188+
showSenderName: info.showSenderName,
189+
onToggleReaction: { key in
190+
onToggleReaction(message.eventID, key)
191+
},
192+
onTapReply: { eventID in
193+
onTapReply(eventID)
194+
},
195+
onAvatarDoubleTap: {
196+
onAvatarDoubleTap(message)
197+
},
198+
onUserTap: { userId in
199+
onUserTap(userId)
200+
},
201+
onRoomTap: onRoomTap,
202+
currentUserID: currentUserID
203+
)
204+
}
156205
.id(message.id)
157206
.help(message.formattedTime)
158207
.onAppear { onAppear(row) }

Relay/Views/Timeline/TimelineTableView.swift

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ final class TimelineSwipeState {
2929
var swipingMessageId: String?
3030
/// The current horizontal offset of the swipe gesture.
3131
var offset: CGFloat = 0
32+
/// When `true`, the action bar is locked open and awaiting a button tap.
33+
var isLocked = false
3234
}
3335

3436
/// A proxy that holds a reference to the ``TimelineTableViewController``
@@ -77,14 +79,18 @@ final class BottomAnchoredTableView: NSTableView {
7779
var onSwipeDelta: ((Int, CGFloat) -> Void)?
7880
/// Called with the row index when a horizontal swipe gesture ends.
7981
var onSwipeEnd: ((Int) -> Void)?
82+
/// Called when the user clicks on the table while the action bar is locked open.
83+
var onDismissActionBar: (() -> Void)?
84+
/// Whether the action bar is currently locked open (checked for left-swipe dismiss).
85+
var isActionBarLocked: (() -> Bool)?
8086

8187
private enum GestureAxis { case undecided, horizontal, vertical }
8288
private var gestureAxis: GestureAxis = .undecided
8389
private var accumulatedDeltaX: CGFloat = 0
8490
private var swipingRow: Int = -1
8591
private let axisLockThreshold: CGFloat = 4
8692
private let triggerThreshold: CGFloat = 40
87-
private let maxOffset: CGFloat = 100
93+
private let maxOffset: CGFloat = 220
8894

8995
override func scrollWheel(with event: NSEvent) {
9096
switch event.phase {
@@ -106,10 +112,17 @@ final class BottomAnchoredTableView: NSTableView {
106112
let absX = abs(event.scrollingDeltaX)
107113
let absY = abs(event.scrollingDeltaY)
108114
if absX + absY >= axisLockThreshold {
109-
if absX > absY && event.scrollingDeltaX > 0 {
115+
let locked = isActionBarLocked?() ?? false
116+
if absX > absY && (event.scrollingDeltaX > 0 || locked) {
110117
gestureAxis = .horizontal
111118
accumulatedDeltaX = max(0, event.scrollingDeltaX)
112-
onSwipeDelta?(swipingRow, clampedOffset(accumulatedDeltaX))
119+
if locked && event.scrollingDeltaX < 0 {
120+
// Swiping left while locked — dismiss.
121+
onDismissActionBar?()
122+
gestureAxis = .undecided
123+
} else {
124+
onSwipeDelta?(swipingRow, clampedOffset(accumulatedDeltaX))
125+
}
113126
} else {
114127
gestureAxis = .vertical
115128
super.scrollWheel(with: event)
@@ -140,6 +153,11 @@ final class BottomAnchoredTableView: NSTableView {
140153
}
141154
}
142155

156+
override func mouseDown(with event: NSEvent) {
157+
onDismissActionBar?()
158+
super.mouseDown(with: event)
159+
}
160+
143161
private func clampedOffset(_ delta: CGFloat) -> CGFloat {
144162
if delta <= triggerThreshold {
145163
return delta
@@ -295,6 +313,13 @@ final class TimelineTableViewController: NSViewController {
295313
tableView.onSwipeEnd = { [weak self] row in
296314
self?.handleSwipeEnd(row: row)
297315
}
316+
tableView.onDismissActionBar = { [weak self] in
317+
guard let self, self.swipeState.isLocked else { return }
318+
self.dismissSwipeActionBar()
319+
}
320+
tableView.isActionBarLocked = { [weak self] in
321+
self?.swipeState.isLocked ?? false
322+
}
298323

299324
scrollView.documentView = tableView
300325
scrollView.hasVerticalScroller = true
@@ -578,26 +603,46 @@ final class TimelineTableViewController: NSViewController {
578603

579604
private func handleSwipeDelta(row: Int, offset: CGFloat) {
580605
guard row >= 0, row < rows.count else { return }
606+
// If the action bar is locked and a new swipe starts, dismiss first.
607+
if swipeState.isLocked {
608+
swipeState.isLocked = false
609+
}
581610
swipeState.swipingMessageId = rows[row].message.id
582611
swipeState.offset = offset
583612
}
584613

585614
private func handleSwipeEnd(row: Int) {
586615
let triggerThreshold: CGFloat = 40
616+
let lockOffset: CGFloat = 100
617+
let longSwipeThreshold: CGFloat = 180
587618
let triggered = swipeState.offset >= triggerThreshold
588619

589-
// Animate the offset back to zero.
620+
if triggered, row >= 0, row < rows.count, !rows[row].message.isSystemEvent {
621+
if swipeState.offset >= longSwipeThreshold {
622+
// Long swipe triggers reply directly.
623+
dismissSwipeActionBar()
624+
callbacks.onSwipeReply(rows[row])
625+
} else {
626+
// Short swipe locks the action bar open.
627+
withAnimation(.snappy(duration: 0.25)) {
628+
swipeState.offset = lockOffset
629+
swipeState.isLocked = true
630+
}
631+
}
632+
} else {
633+
dismissSwipeActionBar()
634+
}
635+
}
636+
637+
/// Dismisses the swipe action bar with animation.
638+
func dismissSwipeActionBar() {
590639
withAnimation(.snappy(duration: 0.25)) {
591640
swipeState.offset = 0
641+
swipeState.isLocked = false
592642
}
593-
// Clear the swiping ID after the animation settles.
594643
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
595644
self?.swipeState.swipingMessageId = nil
596645
}
597-
598-
if triggered, row >= 0, row < rows.count, !rows[row].message.isSystemEvent {
599-
callbacks.onSwipeReply(rows[row])
600-
}
601646
}
602647

603648
// MARK: - Height Cache

0 commit comments

Comments
 (0)