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
23 changes: 22 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -628,10 +628,31 @@ final class ConversationController {
refreshFeedPreview(for: conversationID)
}

/// Seats a group the signed-in user just created, so its screen and the Chats list have it
/// without waiting for the next feed fetch.
///
/// Separate from ``join(conversationID:)`` because there is nothing to join: `StartChat` returns
/// the creator already seated, and the metadata it returns is the same shape `joinChat` gives
/// back, so everything after the RPC is identical.
func seatCreatedGroup(_ conversation: Conversation) {
store.apply(.metadataRefresh(conversation))
store.setMembership(true, in: conversation.id)
persistConversation(conversation)
persistMembership(true, in: conversation.id)
refreshFeedPreview(for: conversation.id)
}

/// Leaves a group. The chat stays in the store — the screen the user left from is still on top and
/// needs it to render the gate — but drops out of ``joinedGroups``, so it leaves the Chats list.
///
/// `notFound` counts as having left. It means the server holds no membership to remove, so the
/// local flag is the stale one and clearing it is what reconciles the two; throwing would leave
/// the user in a chat the server says they are not in.
func leave(conversationID: ConversationID) async throws {
try await membership.leaveChat(owner: owner, conversationID: conversationID)
do {
try await membership.leaveChat(owner: owner, conversationID: conversationID)
} catch ErrorLeaveChat.notFound {
}
store.setMembership(false, in: conversationID)
persistMembership(false, in: conversationID)
}
Expand Down
21 changes: 17 additions & 4 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,26 @@ struct DeepLinkAction {
private static func routeChat(_ conversationID: ConversationID, in container: SessionContainer) async {
let conversation = await container.conversationController.hydratedConversation(withID: conversationID)

switch conversation?.type {
case .tipDm, .group:
container.appRouter.navigate(to: .tipConversation(conversationID))
case .contactDm, nil:
guard let destination = chatDestination(for: conversation?.type, conversationID: conversationID) else {
logger.info("Ignoring non-tip chat deeplink", metadata: [
"conversationID": "\(conversationID)",
])
return
}

container.appRouter.navigate(to: destination)
}

/// The screen a chat id opens, or nil when it isn't one the app surfaces.
///
/// A group routes to the same transcript a tip DM does whether or not the viewer is a member:
/// the screen gates itself, so an invite link and a push tap land on one destination.
static func chatDestination(for type: ConversationType?, conversationID: ConversationID) -> AppRouter.Destination? {
switch type {
case .tipDm, .group:
return .tipConversation(conversationID)
case .contactDm, nil:
return nil
}
}

Expand Down
7 changes: 6 additions & 1 deletion Flipcash/Core/Controllers/Deep Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ nonisolated extension Route {
guard components.count > 1 else {
return nil
}
guard let id = ConversationID(base64URLEncoded: components[1]) else {
// A group's id is a 16-byte UUID and travels as the dashed form
// an invite link carries; a DM's is a 32-byte hash with no UUID
// form, so it stays base64url. Both are in circulation.
guard let id = ConversationID(uuidString: components[1])
?? ConversationID(base64URLEncoded: components[1])
else {
return nil
}
// `/chat/{id}/send` opens the Send Cash sheet over the chat.
Expand Down
26 changes: 19 additions & 7 deletions Flipcash/Core/Controllers/PushController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,21 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification

postContactJoinIfNeeded(response.notification.request.content.userInfo)

if response.actionIdentifier == ChatNotificationCategory.sendCashActionID,
let conversationID = NotificationPayload.chatID(response.notification.request.content.userInfo)
{
let url = URL(string: "flipcash://chat/\(conversationID.base64URLEncoded)/send")!
handleTargetUrlIfNeeded(url.absoluteString)
let userInfo = response.notification.request.content.userInfo
let conversationID = NotificationPayload.chatID(userInfo)

if let conversationID {
// The push carries the chat it is about, so the tap routes on that
// rather than on `target_url`: the same id an invite link carries,
// through the same route, so a group the user hasn't joined lands on
// the gated screen either way. `target_url` stays the fallback for
// every non-chat push.
handleDeepLink(URL.chatDeepLink(
for: conversationID,
sendCash: response.actionIdentifier == ChatNotificationCategory.sendCashActionID
))
} else {
let aps = response.notification.request.content.userInfo["aps"] as? [String: Any]
let aps = userInfo["aps"] as? [String: Any]
handleTargetUrlIfNeeded(aps?["target_url"] as? String)
}

Expand All @@ -346,7 +354,11 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification
return
}

logger.debug("Forwarding notification target_url to deep link handler", metadata: ["url": "\(url.sanitizedForAnalytics)"])
handleDeepLink(url)
}

private func handleDeepLink(_ url: URL) {
logger.debug("Forwarding notification deep link to handler", metadata: ["url": "\(url.sanitizedForAnalytics)"])

Task { @MainActor in
NotificationCenter.default.post(
Expand Down
10 changes: 8 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ extension AppRouter {
/// Finding someone by their Flipcash handle, opened from the Chats tab.
/// Distinct from `.username`, which claims the signed-in user's own.
case usernameLookup
/// The list of ways to start a chat, pushed from the Chats tab's `+`.
case newChat
/// The "New Public Group" form, opened from the New Chat screen.
case newPublicGroup
/// A tip DM conversation, pushed onto the `.tips` stack — from the
/// Tips list or a tip-DM push notification.
case tipConversation(ConversationID)
Expand Down Expand Up @@ -126,7 +130,7 @@ extension AppRouter {
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw:
return .you
case .profileName, .profilePhoto, .tipcard, .usernameLookup,
case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
.tipConversation, .tipConversationWithKeyboard, .tipConversationForUser,
.userProfile, .chatProfile:
return .tips
Expand Down Expand Up @@ -171,6 +175,8 @@ extension AppRouter {
case .profilePhoto: "profilePhoto"
case .tipcard: "tipcard"
case .usernameLookup: "usernameLookup"
case .newChat: "newChat"
case .newPublicGroup: "newPublicGroup"
case .tipConversation: "tipConversation"
case .tipConversationWithKeyboard: "tipConversationWithKeyboard"
case .tipConversationForUser: "tipConversationForUser"
Expand Down Expand Up @@ -213,7 +219,7 @@ extension AppRouter {
.settingsAdvancedFeatures,
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw,
.profileName, .profilePhoto, .tipcard, .usernameLookup:
.profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup:
return nil
}
}
Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ struct DestinationView: View {
case .usernameLookup:
UsernameLookupScreen()

case .newChat:
NewChatScreen()

case .newPublicGroup:
NewPublicGroupScreen()

case .tipConversation(let conversationID):
// `.id` forces fresh view identity per conversation.
ConversationScreen(context: .existing(conversationID))
Expand Down
16 changes: 13 additions & 3 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
/// Fired when the user taps the transcript's head card — the counterpart's in a tip DM, the
/// chat's own in a group; nil disables the card tap.
let onProfileTap: (() -> Void)?
/// Fired when the user taps the group head card's "Invite People To Join". The card draws the
/// button only when it both asks for it and this is set.
let onGroupInvite: (() -> Void)?
/// Fired when the user taps an author's face in a group's gutter, with that author's user id.
let onAuthorTap: (UserID) -> Void
/// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never
Expand Down Expand Up @@ -68,8 +71,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
/// Whether the chat's participation rules leave this user anything to type, and whether they
/// may read at all. Drives the gate panel in place of the bar and the blur over the transcript.
let gate: ConversationGatePresentation
/// Ticker for the mint the gate's requirement names, once resolved.
let gateSymbol: String?
/// Whether the gate's decorative shapes are drawn behind the blur — see
/// ``GatePreviewPlaceholder``. Set for a chat the viewer cannot read and has no history of.
let showsGatePlaceholder: Bool
/// Display name of the mint the gate's requirement names, once resolved.
let gateMintName: String?
/// Opens the buy or add-cash flow from the gate panel's CTA.
let onGateAddFunds: () -> Void
/// Joins the chat from the gate panel's Join button.
Expand All @@ -89,6 +95,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
let screen = ChatScreenViewController(bar: barHost.view, barController: barHost)
screen.focusesComposerOnAppear = focusOnAppear
screen.isTranscriptObscured = gate.obscuresTranscript
screen.showsGatePlaceholder = showsGatePlaceholder
screen.authorAvatars = authorAvatars
screen.onReachTop = onReachTop
screen.onRetry = onRetry
Expand All @@ -97,6 +104,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onLinkCardTap = onLinkCardTap
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onGroupInvite = onGroupInvite
screen.onAuthorTap = onAuthorTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onQuoteTap = { [weak screen] stableID in
Expand All @@ -118,6 +126,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
// focus survive across updates.
context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator)
screen.isTranscriptObscured = gate.obscuresTranscript
screen.showsGatePlaceholder = showsGatePlaceholder
screen.authorAvatars = authorAvatars
screen.onReachTop = onReachTop
screen.onRetry = onRetry
Expand All @@ -126,6 +135,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onLinkCardTap = onLinkCardTap
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onGroupInvite = onGroupInvite
screen.onAuthorTap = onAuthorTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onQuoteTap = { [weak screen] stableID in
Expand Down Expand Up @@ -201,7 +211,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
isTipDm: isTipDm,
startChattingFee: startChattingFee,
gate: gate,
gateSymbol: gateSymbol,
gateMintName: gateMintName,
onGateAddFunds: onGateAddFunds,
onGateJoin: onGateJoin,
isJoiningChat: isJoiningChat
Expand Down
19 changes: 14 additions & 5 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ struct ConversationBottomBar: View {
/// Whether the chat's participation rules leave this user anything to type. Anything but
/// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel.
var gate: ConversationGatePresentation = .open
/// Ticker for the mint the gate's requirement names, once resolved. See ``ConversationGatePanel``.
var gateSymbol: String? = nil
/// Display name of the mint the gate's requirement names, once resolved. See
/// ``ConversationGatePanel``.
var gateMintName: String? = nil
/// Opens the buy or add-cash flow from the gate panel's CTA.
var onGateAddFunds: () -> Void = {}
/// Joins the chat from the gate panel's Join button.
Expand All @@ -75,16 +76,24 @@ struct ConversationBottomBar: View {
// The gate panel takes the bar whole rather than sitting inside it: none of the composer's
// springs key on state a gated user can change, and the surface underneath is the same slab
// either way, so the bar still reads as the bottom of the screen.
if gate.replacesComposer {
switch gate {
case .undetermined:
// Nothing is drawn at all until the rules land. A composer would be an affordance the
// server may refuse, and a panel would have to name a requirement we haven't been told.
// The bar arriving with the metadata is the honest version of both.
EmptyView()

case .join, .blocked, .readOnly:
ConversationGatePanel(
presentation: gate,
symbol: gateSymbol,
mintName: gateMintName,
onAddFunds: onGateAddFunds,
onJoin: onGateJoin,
isJoining: isJoiningChat
)
.modifier(BarSurfaceBackground())
} else {

case .open:
composerBar
}
}
Expand Down
52 changes: 43 additions & 9 deletions Flipcash/Core/Screens/Conversation/ConversationGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,26 @@ private func verdict(for unmet: [ConversationGateRequirement]) -> ConversationGa
enum ConversationGatePresentation: Equatable {
/// Nothing gated — the ordinary composer.
case open
/// Rules met but not yet a member: a readable transcript with Join Chat in
/// The chat's rules aren't known yet, because its metadata hasn't arrived.
///
/// Distinct from ``open``, which is a chat that has told us it gates
/// nothing. A chat reached by link or push is not in the store until
/// `GetChat` returns, and treating that absence as "no rules" hands a
/// readable transcript and a working composer to someone who may be
/// entitled to neither, then takes both back when the metadata lands —
/// showing the thing being withheld. Withholding has to be immediate;
/// revealing is the part that can wait for an answer.
case undetermined
/// Rules met but not yet a member: a blurred transcript with Join Chat in
/// place of the composer, carrying the chat's stated requirement to restate
/// what joining costs (node 10125:19102).
/// what joining costs (node 10125:19102 for the panel's copy).
///
/// The transcript is withheld here even though the listener rules are
/// satisfied, because joining is what unblurs: satisfying a chat's rules
/// earns the right to join it, not the right to read it from outside.
/// Eligibility decides what the button offers — Join Chat here, a buy in
/// ``blocked`` — never whether the messages are legible. Android draws the
/// same line.
case join(ConversationGateRequirement?)
/// Listener rules unmet: blurred transcript, the requirement named, and a
/// CTA when the requirement has one (node 10125:19153).
Expand All @@ -210,21 +227,37 @@ enum ConversationGatePresentation: Equatable {

/// Whether the transcript is blurred and its messages left unfetched.
///
/// Only ``blocked``. Listener rules gate *reading*, so anyone who satisfies
/// them may read whether or not they have joined — which is why the design
/// shows a qualified non-member the real transcript with Join Chat under it.
/// Every state a non-member can be in: ``blocked`` refuses them, ``join``
/// has not been accepted yet, and ``undetermined`` hasn't said. Membership
/// is the line, not eligibility — see ``join``. ``readOnly`` is not here
/// because that user has already joined; they just can't send.
var obscuresTranscript: Bool {
switch self {
case .open, .join, .readOnly: return false
case .blocked: return true
case .open, .readOnly: return false
case .join, .blocked, .undetermined: return true
}
}

/// Whether the blur is covering messages the viewer is being kept from,
/// rather than an unanswered question.
///
/// The gate placeholder's shapes stand in for a transcript that exists and
/// is being withheld, which ``blocked`` and ``join`` can both claim: the
/// chat is real and its messages are not this viewer's to read yet. Under
/// ``undetermined`` nothing is known to be withheld, so the blur covers an
/// empty screen and says nothing about why.
var withholdsTranscript: Bool {
switch self {
case .open, .readOnly, .undetermined: return false
case .join, .blocked: return true
}
}

/// Whether the composer is replaced by a gate panel.
var replacesComposer: Bool {
switch self {
case .open: return false
case .join, .blocked, .readOnly: return true
case .open: return false
case .join, .blocked, .readOnly, .undetermined: return true
}
}
}
Expand All @@ -241,6 +274,7 @@ func conversationGatePresentation(_ gate: ConversationGate, isMember: Bool) -> C
case .unsatisfied(_, let primary):
return .blocked(primary)
case .satisfied:
// Satisfying the rules earns the join, not the transcript — `.join` blurs.
guard isMember else { return .join(gate.headline) }
switch gate.speaker {
case .unsatisfied(_, let primary): return .readOnly(primary)
Expand Down
Loading
Loading