Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f238069
feat(chat): open message links on the linked message
bmc08gt Oct 14, 2026
7f6d0c7
test(chat): cover message-link landing with a UI test
bmc08gt Oct 14, 2026
47b9c83
Merge remote-tracking branch 'origin/main' into feat/message-link-route
bmc08gt Oct 14, 2026
2d7ae6e
fix(chat): draw the message link back chip in glass
bmc08gt Oct 16, 2026
ee69b47
fix(chat): keep the message-link back chip until it is tapped or the …
bmc08gt Oct 16, 2026
7aedfca
fix(chat): return from a message link to the chat as the reader left it
bmc08gt Oct 16, 2026
75ed2da
fix(chat): label the message link back chip with where it goes
bmc08gt Oct 16, 2026
a29a3a5
feat(chat): draw message links as a message card
bmc08gt Oct 14, 2026
411219b
fix(chat): match the message card faces to the design
bmc08gt Oct 14, 2026
8359957
fix(chat): size the message card at the cash card width
bmc08gt Oct 14, 2026
73b885e
fix(chat): show a link to a system notice as the plain link card
bmc08gt Oct 14, 2026
344befc
fix(chat): name a message card's author the way the chat rows do
bmc08gt Oct 16, 2026
5640e8a
fix(chat): size message card text to match Android
bmc08gt Oct 16, 2026
3e23722
fix(chat): lighten the message card's byline and footer
bmc08gt Oct 16, 2026
49a7c92
feat(chat): draw photos and cash links on message cards
bmc08gt Oct 16, 2026
1b340f9
feat(chat): draw the chat's avatar on message cards
bmc08gt Oct 16, 2026
a9fbab9
fix(chat): draw the message card as one identity block over a quieter…
bmc08gt Oct 16, 2026
7badc1f
fix(chat): date message cards relative to now
bmc08gt Oct 16, 2026
e3c5708
fix(chat): set message card body text in medium
bmc08gt Oct 16, 2026
082d830
feat(chat): forward a message through the share sheet
bmc08gt Oct 16, 2026
dc05be7
fix(chat): draw the message menu icons filled
bmc08gt Oct 16, 2026
89c99f3
Merge remote-tracking branch 'origin/main' into feat/message-link-card
bmc08gt Oct 16, 2026
bc5b0f0
Merge remote-tracking branch 'origin/feat/message-link-card' into fea…
bmc08gt Oct 16, 2026
a8334ae
Merge remote-tracking branch 'origin/main' into feat/message-link-share
bmc08gt Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1834,7 +1834,7 @@ final class ConversationController {
case .delete: "Couldn't Delete Message"
// Neither of these mutates the transcript, so neither can raise a mutation alert.
// Report files straight from its sheet and reports its own outcome there.
case .copy, .reply, .report: "Something Went Wrong"
case .copy, .reply, .report, .forward: "Something Went Wrong"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,10 +601,11 @@ extension ChatItem {
/// Menu order is fixed here, not at the call site — a `Set` has no order, and the context menu
/// must not shuffle its rows between renders of the same message.
///
/// Report goes last: it is the rarest row and the only one that never appears on your own
/// message, so the menu reads "yours ends with delete, theirs ends with report".
/// Forward follows Reply, as on Android. Report goes
/// last: it is the rarest row and the only one that never appears on your own message, so the
/// menu reads "yours ends with delete, theirs ends with report".
nonisolated private static func orderedActions(_ capabilities: Set<MessageCapability>) -> [MessageCapability] {
[.copy, .reply, .edit, .delete, .report].filter(capabilities.contains)
[.copy, .reply, .forward, .edit, .delete, .report].filter(capabilities.contains)
}

/// "Read 3:42 PM" / "Read Yesterday" / "Read Monday" / "Read Tue, Jun 17" once the counterpart's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen?.focusComposer()
case .reply:
screen?.focusComposer()
// Both raise a sheet over the transcript, so the keyboard goes down first.
case .delete, .report: screen?.dismissKeyboard()
case .copy: break
// Each raises a sheet over the transcript, so the keyboard goes down first.
case .delete, .report, .forward: screen?.dismissKeyboard()
case .copy: break
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ struct ConversationScreen: View {
/// Whether the encryption explainer opened from the transcript's marker is showing.
@State private var isShowingEncryptionInfo = false
@State private var messageReport: MessageReportRequest?
@State private var messageShare: MessageShareRequest?
/// Whether `Group: Gate Shown` has gone out for this visit. The destination is keyed by
/// conversation ID, so a new push is a new visit; a return from a pushed screen is not.
@State private var didReportGate = false
Expand Down Expand Up @@ -805,6 +806,20 @@ struct ConversationScreen: View {
.sheet(isPresented: $isShowingEncryptionInfo) {
E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo)
}
.fullScreenCover(item: $messageShare) { share in
ShareToChatsSheet(
subject: .message(share.url),
isPresented: Binding(
get: { messageShare != nil },
set: { if !$0 { messageShare = nil } }
)
) { chatID in
// The chat the message is in stays in the picker, and sending there leaves the
// viewer where they already are.
guard chatID != conversationID else { return }
router.push(.tipConversation(chatID))
}
}
.fullScreenCover(item: $messageReport) { report in
NavigationStack {
ReportFlowScreen(
Expand Down Expand Up @@ -1032,6 +1047,9 @@ struct ConversationScreen: View {
// that one.
guard let conversationID else { return }
messageReport = MessageReportRequest(id: stableID, chatID: conversationID, messageID: message.id)
case .forward:
guard let conversationID else { return }
messageShare = MessageShareRequest(id: stableID, url: .messageLink(in: conversationID, messageID: message.id))
}
}

Expand Down Expand Up @@ -1129,6 +1147,12 @@ struct ConversationScreen: View {
let messageID: MessageID
}

private struct MessageShareRequest: Identifiable {
/// The transcript row's stable id.
let id: String
let url: URL
}

private struct ReactionPickerRequest: Identifiable {
let id: String
let messageID: MessageID
Expand Down
29 changes: 20 additions & 9 deletions Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ enum ShareToChatsSubject {
preview: TipCodePreview? = nil,
directChatID: ConversationID?
)
/// A link to one message, `/chat/{chatId}/m/{seq}`. The recipient's app draws the message card
/// from it, so the link is all that goes out.
case message(URL)
}

/// "Invite People" (nodes 10330:19387, 10330:19549, 10329:12104): hand out a link by Share or Copy,
Expand Down Expand Up @@ -65,6 +68,7 @@ struct ShareToChatsSheet: View {
let url: URL = switch subject {
case .group(let id): .groupChatInvite(for: id)
case .user(let userID, let username, _, _, _): .tipcard(for: userID, username: username)
case .message(let url): url
}
self._model = State(initialValue: InvitePeopleViewModel(url: url))
}
Expand All @@ -73,7 +77,7 @@ struct ShareToChatsSheet: View {
private var groupID: ConversationID? {
switch subject {
case .group(let id): id
case .user: nil
case .user, .message: nil
}
}

Expand All @@ -82,14 +86,16 @@ struct ShareToChatsSheet: View {
switch subject {
case .group(let id): id
case .user(_, _, _, _, let directChatID): directChatID
// The message's own chat stays: a link sent there points at something further up.
case .message: nil
}
}

/// The person whose card Show Profile Card draws. Nil for a group, and for a person with no
/// display name, who has no card.
private var card: ProfileCardScreen.Card? {
switch subject {
case .group:
case .group, .message:
return nil
case .user(let userID, let username, let displayName, _, _):
guard let displayName, !displayName.isEmpty else { return nil }
Expand All @@ -101,20 +107,21 @@ struct ShareToChatsSheet: View {
switch subject {
case .group: "Invite People"
case .user: "Share Profile"
case .message: "Share Message"
}
}

private var copyTitle: String {
switch subject {
case .group: "Copy Invite Link"
case .user: "Copy Link"
case .user, .message: "Copy Link"
}
}

private var sendTitle: String {
switch subject {
case .group: "Invite"
case .user: "Send"
case .user, .message: "Send"
}
}

Expand Down Expand Up @@ -149,13 +156,17 @@ struct ShareToChatsSheet: View {
.flatMap(UIImage.init(data:))
}

/// What Share hands the system share sheet: the group's preview card, or the person's.
private func shareItem() -> UIActivityItemSource {
/// What Share hands the system share sheet: the group's preview card, the person's, or a message's bare link.
private func shareItems() -> [Any] {
switch subject {
case .group:
GroupInviteShareItem(url: model.url, title: title, icon: icon())
[GroupInviteShareItem(url: model.url, title: title, icon: icon())]
case .user(_, _, let displayName, let preview, _):
TipCodeShareItem.profile(url: model.url, displayName: displayName, preview: preview)
[TipCodeShareItem.profile(url: model.url, displayName: displayName, preview: preview)]
case .message:
// The URL itself, not an item source: the system builds the preview card from the link
// rather than from a group invite's fallback title.
[model.url]
}
}

Expand Down Expand Up @@ -267,7 +278,7 @@ struct ShareToChatsSheet: View {
.scaledToFit()
} action: {
if groupID != nil { Analytics.groupInviteShared(method: .share) }
ShareSheet.present(activityItem: shareItem()) { _ in }
ShareSheet.present(activityItems: shareItems()) { _ in }
}
.accessibilityIdentifier("group-invite-send")

Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Utilities/URL+Links.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ extension URL {
URL(string: "https://app.flipcash.com/chat/\(conversationID.linkPathComponent)")!
}

/// A link to one message — `app.flipcash.com/chat/{chatId}/m/{seq}`, where `seq` is the
/// message's per-chat id. `Route` parses it back to ``Route/Path/chatMessage(_:_:)``.
nonisolated static func messageLink(in conversationID: ConversationID, messageID: MessageID) -> URL {
URL(string: "https://app.flipcash.com/chat/\(conversationID.linkPathComponent)/m/\(messageID.value)")!
}

/// The in-app link a chat opens through, for the push tap that has no `target_url` to follow.
///
/// The same id an invite link carries, routed the same way, so a group the user has not joined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public enum MessageCapability: String, Hashable, Sendable, Codable, CaseIterable
case edit
case delete
case report
/// Send a link to the message into other chats, or out through the system share sheet.
case forward
}

extension MessageCapability {
Expand All @@ -27,14 +29,15 @@ extension MessageCapability {
case .edit: "Edit"
case .delete: "Delete"
case .report: "Report"
case .forward: "Forward"
}
}

/// Whether the menu should render this row in its destructive style.
public var isDestructive: Bool {
switch self {
// Report is red to match Report on the profile menus, though nothing is destroyed.
case .copy, .reply, .edit: false
case .copy, .reply, .edit, .forward: false
case .delete, .report: true
}
}
Expand All @@ -57,6 +60,12 @@ extension MessageCapability {
/// | Own message, unconfirmed | no |
/// | A tombstone | no |
///
/// Forward is granted wherever Android grants `Link`: any
/// text, photo or cash message a participant sent and the server confirmed, since only those
/// have a sequence to point at. A tombstone, a system notice, a widget, and a message this client
/// could not decrypt don't get it. Like Report, it survives `isMember` and `canPost`: a reader
/// outside a group can still pass a message along.
///
/// `conversation` is accepted but unread today: it is the seam a group-chat permissions model
/// plugs into (an admin deleting another member's message, a read-only channel), and taking it
/// now means adding that model does not change every call site. It is optional because the
Expand All @@ -67,8 +76,8 @@ extension MessageCapability {
/// the Join panel stands where the composer would, so there is nothing to reply with.
///
/// `canPost` is false when the viewer fails a speaker rule (with membership). Reply goes with the
/// composer, since a reply is a post, matching Android. Copy, Report and the sender's own
/// Edit/Delete are not posting and stay. Reactions are not decided here.
/// composer, since a reply is a post, matching Android. Copy, Report, the link rows and the
/// sender's own Edit/Delete are not posting and stay. Reactions are not decided here.
///
/// `now` is a parameter rather than `Date.now` so the result stays a function of its inputs.
public static func resolve(
Expand Down Expand Up @@ -105,7 +114,8 @@ extension MessageCapability {
// authored it so there is nothing to edit, and delete is deliberately withheld from
// a payment record. Someone else's payment is still reportable — a payment is a thing
// a person did to you.
return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report]
guard message.isFromSelf(selfUserID) else { return [.reply, .report, .forward] }
return message.eventSequence > 0 ? [.reply, .forward] : [.reply]
case .widget(.unrecognized):
// Drawn as an unsupported message: nothing this client can act on.
return []
Expand All @@ -121,7 +131,9 @@ extension MessageCapability {
}

guard message.isFromSelf(selfUserID) else {
return hasText ? [.copy, .reply, .report] : [.reply, .report]
return hasText
? [.copy, .reply, .report, .forward]
: [.reply, .report, .forward]
}

// An unconfirmed message has no `eventSequence` to send as `expected_event_sequence`, so no
Expand All @@ -131,7 +143,7 @@ extension MessageCapability {
return []
}

var capabilities: Set<MessageCapability> = [.reply]
var capabilities: Set<MessageCapability> = [.reply, .forward]
if hasText {
capabilities.insert(.copy)
if isWithin(policy.editWindow, of: message, at: now) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public struct MessagePolicy: Hashable, Sendable {
switch capability {
case .edit: editWindow
case .delete: deleteWindow
case .copy, .reply, .report: nil
case .copy, .reply, .report, .forward: nil
}
}

Expand Down
Loading
Loading