Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,13 @@ final class ConversationController {
case .deleted:
return nil

case .widget(.shareProfile):
// The server sends it; no user is the sender to name.
return "Shared a profile"

case .widget(.unrecognized):
return nil

case .encrypted:
// No plaintext to preview -- the row still surfaces (it's still the newest activity),
// just with a blank subtitle, same as an empty text body above.
Expand Down Expand Up @@ -1763,7 +1770,7 @@ final class ConversationController {
case .text(let text):
store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID)
_ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID)
case .encrypted, .cash, .deleted:
case .encrypted, .cash, .deleted, .widget:
if case .failure(let error) = Result(catching: { try pending.content.asProto() }) {
logger.error("Cannot retry a send this client has no path to re-send", metadata: [
"conversationID": "\(conversationID)",
Expand Down
35 changes: 32 additions & 3 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ extension ChatItem {
/// Whether the viewer may add or remove a reaction — false for someone previewing a group
/// they have not joined (see `ChatMessage.canReact`).
canReact: Bool = true,
/// Whether the chat's speaker rule lets the viewer speak. A widget has no author to gate on,
/// so its reactions follow this in addition to `canReact`.
canSpeak: Bool = true,
/// The card, if any, for a message's detected links. Injected like the other collaborators
/// so the mapper stays pure; the screen supplies classification and nothing else, and it
/// defaults to no card. Takes the detected links rather than the text so the detector runs
Expand Down Expand Up @@ -151,7 +154,7 @@ extension ChatItem {
func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool {
switch message.content {
case .text(let text): EmojiOnlyDetector.isEmojiOnly(text)
case .cash, .deleted, .encrypted: false
case .cash, .deleted, .encrypted, .widget: false
}
}
func rendersBare(_ message: ConversationMessage) -> Bool {
Expand All @@ -164,7 +167,7 @@ extension ChatItem {
let layouts = messages.map { message in
switch message.content {
case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard))
case .cash, .deleted, .encrypted: [RowLayout(part: nil, text: nil, preview: nil)]
case .cash, .deleted, .encrypted, .widget: [RowLayout(part: nil, text: nil, preview: nil)]
}
}
// A status line under a message sits between it and the next bubble, so it ends the bubble
Expand Down Expand Up @@ -251,6 +254,11 @@ extension ChatItem {
? "You deleted this message"
: "This message was deleted"
)
case .widget(.shareProfile(let share)):
content = .shareProfile(LinkCard.User(profileOf: share.username))
case .widget(.unrecognized):
// A widget variant this build doesn't draw; a newer one will.
content = .unavailable(.updateApp)
case .encrypted:
// Still encrypted here means decryption failed; the awaiting ones were dropped above.
content = .unavailable(.decryptFailure(
Expand Down Expand Up @@ -347,7 +355,7 @@ extension ChatItem {
// last row, with its receipt.
reactions: isLast ? message.reactionState?.pills ?? [] : [],
selfReactions: message.reactionState?.selfReactions ?? [],
canReact: canReact,
canReact: canReact && (isWidget(message) ? canSpeak : true),
isUnsent: isUnsent
)))
}
Expand Down Expand Up @@ -407,6 +415,22 @@ extension ChatItem {
kind: .unavailable,
authorID: original.senderID
)
case .widget(.shareProfile):
return ChatQuote(
stableID: original.stableID,
authorName: authorName,
snippet: ChatQuote.sharedProfileSnippet,
kind: .text,
authorID: original.senderID
)
case .widget(.unrecognized):
return ChatQuote(
stableID: nil,
authorName: authorName,
snippet: ChatQuote.unavailableSnippet,
kind: .unavailable,
authorID: original.senderID
)
case .encrypted:
// No plaintext to preview -- same unavailable treatment as a quote whose original the
// local database never saw.
Expand Down Expand Up @@ -522,6 +546,11 @@ extension ChatItem {
///
/// 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 isWidget(_ message: ConversationMessage) -> Bool {
if case .widget = message.content { return true }
return false
}

nonisolated private static func orderedActions(_ capabilities: Set<MessageCapability>) -> [MessageCapability] {
[.copy, .reply, .edit, .delete, .report].filter(capabilities.contains)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
/// Fired when the user taps an `@handle` in a message. The owner looks the handle up and opens
/// that person's profile.
let onMentionTap: (Username) -> Void
/// Fired when the Share button on a shared-profile widget is tapped.
let onShareProfile: (LinkCard.User) -> Void
let ownProfile: OwnProfileCard?
/// Fired when the user taps the card drawn in place of a link, with the whole card and the
/// stable id of the message it came on. The owner decides where it lands, which differs by kind.
let onLinkCardTap: (LinkCard, String) -> Void
Expand Down Expand Up @@ -134,6 +137,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onCashCardTap = onCashCardTap
screen.onOpenURL = onOpenURL
screen.onMentionTap = onMentionTap
screen.onShareProfile = onShareProfile
screen.ownProfile = ownProfile
screen.onLinkCardTap = onLinkCardTap
screen.linkCardSource = linkCardSource
screen.onContactAction = onContactAction
Expand Down Expand Up @@ -177,6 +182,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onCashCardTap = onCashCardTap
screen.onOpenURL = onOpenURL
screen.onMentionTap = onMentionTap
screen.onShareProfile = onShareProfile
screen.ownProfile = ownProfile
screen.onLinkCardTap = onLinkCardTap
screen.linkCardSource = linkCardSource
screen.onContactAction = onContactAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ final class ConversationLoadCoordinator {
/// scope, so whatever it reads (the contact directory, the conversation) re-triggers mapping.
/// Nil for a chat with no counterpart to card.
private let profileCard: @MainActor () -> ChatProfileCard?
/// Whether the chat's speaker rule lets the viewer speak, evaluated inside the observation scope
/// so a balance or rule change re-maps. Gates Reply and reactions on a widget.
private let canSpeak: @MainActor () -> Bool
/// Names senders the chat's own roster leaves out, from what the device already knows about
/// them. Observed like every other input, so a reload re-attributes the window in place.
private let knownAuthors: KnownAuthorDirectory
Expand Down Expand Up @@ -87,8 +90,10 @@ final class ConversationLoadCoordinator {
controller: ConversationController,
session: Session,
knownAuthors: KnownAuthorDirectory,
profileCard: @escaping @MainActor () -> ChatProfileCard?
profileCard: @escaping @MainActor () -> ChatProfileCard?,
canSpeak: @escaping @MainActor () -> Bool = { true }
) {
self.canSpeak = canSpeak
self.conversationID = conversationID
self.controller = controller
self.session = session
Expand Down Expand Up @@ -267,6 +272,7 @@ final class ConversationLoadCoordinator {
now: capabilityClock,
namesAuthors: namesAuthors,
canReact: conversation.map { controller.isMember(of: $0) } ?? true,
canSpeak: canSpeak(),
// Only a transcript that attributes its rows has anything to resolve, so a DM never
// takes a dependency on the directory and never re-maps when it reloads.
knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty,
Expand Down Expand Up @@ -316,6 +322,7 @@ final class ConversationLoadCoordinator {
in: inputs.conversation,
as: inputs.selfUserID,
isMember: inputs.isMember,
canSpeak: inputs.canSpeak,
policy: inputs.policy,
now: inputs.now
)
Expand All @@ -327,6 +334,7 @@ final class ConversationLoadCoordinator {
author: { message in message.senderID.flatMap { authors[$0] } },
namesAuthors: inputs.namesAuthors,
canReact: inputs.canReact,
canSpeak: inputs.canSpeak,
// Classification is pure and host-gated, and that is all mapping does with a link: the
// card is the link's identity, and the card view looks it up for itself. So nothing
// here touches the network, and an answer landing cannot re-diff this window.
Expand Down Expand Up @@ -431,6 +439,8 @@ final class ConversationLoadCoordinator {
/// Whether the viewer may add or remove a reaction — false for someone previewing a group
/// they have not joined. True for a DM, where there is no conversation record to preview.
var canReact: Bool
/// The chat's speaker rule as the viewer stands against it; see ``ConversationLoadCoordinator/canSpeak``.
var canSpeak: Bool
/// Identities for senders the chat's own roster leaves out. Compared by identity — see
/// ``KnownAuthorDirectory/Snapshot``.
var knownAuthors: KnownAuthorDirectory.Snapshot
Expand Down
45 changes: 43 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,8 @@ struct ConversationScreen: View {
onCashCardTap: openCurrencyInfo,
onOpenURL: openLink,
onMentionTap: openMention,
onShareProfile: shareProfile,
ownProfile: ownProfile,
onLinkCardTap: openLinkCard,
linkCardSource: sessionContainer.linkCardFeed,
onContactAction: openContactCard,
Expand Down Expand Up @@ -703,6 +705,11 @@ struct ConversationScreen: View {
picture: groupConversation?.picture
)
}
// Fetch the signed-in user's own avatar for a shared-profile widget that names them.
.task(id: sessionContainer.session.profile?.profilePicture?.thumbnailBlobID) {
guard let picture = sessionContainer.session.profile?.profilePicture else { return }
await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: picture)
}
// Fetch the tip counterpart's avatar for the title and profile card.
.task(id: tipCounterpart?.userID) {
await sessionContainer.profileAvatars.load(
Expand Down Expand Up @@ -1074,7 +1081,9 @@ struct ConversationScreen: View {
)
case .deleted:
(ChatQuote.deletedSnippet, .unavailable)
case .encrypted:
case .widget(.shareProfile):
(ChatQuote.sharedProfileSnippet, .text)
case .encrypted, .widget(.unrecognized):
(ChatQuote.unavailableSnippet, .unavailable)
}
}
Expand Down Expand Up @@ -1161,7 +1170,7 @@ struct ConversationScreen: View {
case .cash(let fiat):
Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint)
router.push(.currencyInfo(fiat.mint))
case .text, .deleted, .encrypted:
case .text, .deleted, .encrypted, .widget:
break
}
}
Expand Down Expand Up @@ -1275,6 +1284,31 @@ struct ConversationScreen: View {
).open(url)
}

/// Opens the share sheet on the widget's person, with the public link their own You tab shares.
private func shareProfile(_ card: LinkCard.User) {
let item = TipCodeShareItem(url: card.url, title: card.linkedHandle ?? card.url.absoluteString, preview: nil)
ShareSheet.present(activityItem: item) { _ in }
}

/// The session profile as a card, for a widget naming the viewer's own handle. Read from `body`,
/// so a picture landing redraws it. Nil until the profile has a claimed handle.
private var ownProfile: OwnProfileCard? {
guard let profile = sessionContainer.session.profile, let username = profile.username else { return nil }
let userID = sessionContainer.session.userID
return OwnProfileCard(
username: username,
resolved: LinkCard.User.Resolved(
userID: userID,
isOwn: true,
displayName: profile.displayName ?? "",
handle: username.handle,
joined: nil,
imageData: sessionContainer.profileAvatars.data(for: userID),
blurHash: profile.profilePicture?.thumbnailBlurhash
)
)
}

/// Looks up a tapped `@handle` and opens whoever it names. Resolved on tap rather than as the
/// message maps: a transcript full of handles would otherwise cost a lookup per handle for
/// people nobody taps.
Expand Down Expand Up @@ -1381,6 +1415,13 @@ struct ConversationScreen: View {
// must pick up the conversation the first tip creates.
fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session)
)
},
// The chat's speaker rule, the same `conversationGate` verdict that decides whether
// the composer is live, so a widget's Reply and reactions open and close with it.
canSpeak: { [session, ratesController, conversationController] in
guard let chat = conversationController.conversation(withID: id), chat.type == .group else { return true }
guard conversationController.isMember(of: chat) else { return false }
return conversationGate(session: session, rules: chat.rules, rates: ratesController.cachedRates).isOpen
}
)
}
Expand Down
3 changes: 2 additions & 1 deletion Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ nonisolated struct ChatSpotlightItem {
switch message?.content {
case .text(let text): text
case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())"
case .deleted, .encrypted, nil: nil
case .widget(.shareProfile): "Shared a profile"
case .deleted, .encrypted, .widget(.unrecognized), nil: nil
}
}

Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Utilities/URL+Links.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ extension URL {
static func tipcard(for userID: UserID, username: Username?) -> URL {
let host = "https://flipcash.com"
if let username {
return URL(string: "\(host)/\(username.value)")!
return username.profileURL
}
return URL(string: "\(host)/\(userID.uuidString.lowercased())")!
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable {
/// A message this client cannot display — today, an encrypted one it cannot decrypt. Drawn
/// as a dashed "This message can't be displayed" bubble with `hint` beneath it.
case unavailable(UnavailableHint)
/// A shared profile, drawn as a card with a Share button. The card carries only the
/// handle's link; the name and picture are looked up by the view, as a link card's are.
case shareProfile(LinkCard.User)
}

/// The line under an unavailable message's bubble, chosen by why it can't be shown.
Expand Down Expand Up @@ -144,7 +147,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable {
guard isEmojiOnly, quote == nil, linkPreview == nil else { return false }
switch content {
case .text: return true
case .cash, .deleted, .unavailable: return false
case .cash, .deleted, .unavailable, .shareProfile: return false
}
}

Expand All @@ -166,7 +169,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable {
public var offersReactionStrip: Bool {
switch content {
case .deleted, .unavailable: return false
case .text, .cash: return !isUnsent
case .text, .cash, .shareProfile: return !isUnsent
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ extension ChatItem {
isFromSelf: message.senderID == selfUserID,
senderName: counterpartName
))
case .widget(.shareProfile(let share)):
content = .shareProfile(LinkCard.User(profileOf: share.username))
case .widget(.unrecognized):
content = .unavailable(.updateApp)
case .deleted:
continue // filtered out above; unreachable, kept for switch exhaustiveness
}
Expand All @@ -97,7 +101,7 @@ extension ChatItem {
let isEmojiOnly: Bool
switch message.content {
case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text)
case .cash, .deleted, .encrypted: isEmojiOnly = false
case .cash, .deleted, .encrypted, .widget: isEmojiOnly = false
}

items.append(.message(ChatMessage(
Expand Down
3 changes: 3 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public struct ChatQuote: Hashable, Sendable, Codable {
/// Copy for an original the client cannot show.
public static let unavailableSnippet = "Original message unavailable"

/// The line a quoted shared-profile widget shows: it has no text of its own.
public static let sharedProfileSnippet = "Shared a profile"

/// Copy for an original that has since been deleted.
public static let deletedSnippet = "This message was deleted"

Expand Down
6 changes: 6 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ extension LinkCard {
self.length = range.length
}

/// The card for `username`'s profile link, standing on its own rather than over a span of
/// message text. What a shared-profile widget looks its person up with.
public init(profileOf username: Username) {
self.init(url: username.profileURL, identity: .username(username), range: NSRange(location: 0, length: 0))
}

/// The `@handle` the link itself names, or nil for an id link. What a card with no account
/// behind it shows as the name, since the link is all it has to go on.
public var linkedHandle: String? {
Expand Down
Loading