Skip to content
Open
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
56 changes: 50 additions & 6 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ final class DeepLinkController {

/// The canonical deep-link entry: dedups opens of the same URL within a short window, records
/// analytics, and executes the parsed action. Returns false when the URL parses to no action.
/// `sourceChat` is the chat the link was tapped in, which a message link offers a way back to.
@discardableResult
func open(_ url: URL, origin: DeepLinkOrigin = .link) -> Bool {
func open(_ url: URL, origin: DeepLinkOrigin = .link, sourceChat: ConversationID? = nil) -> Bool {
// Drop duplicate deliveries: a second claim is rejected server-side as
// stale state and surfaces as a false error after the first succeeded.
// A scene link can arrive twice — once from `SceneDelegate`, and
Expand All @@ -61,6 +62,7 @@ final class DeepLinkController {
Analytics.deeplinkOpened(url: url)
var action = handle(open: url)
action?.origin = origin
action?.sourceChat = sourceChat
// Only record a parse result for URLs that resolve to an action. Chat links now route through
// here too, and most are ordinary web URLs — logging every non-match as a "failed to parse"
// error would bury genuine deep-link parse failures in expected noise.
Expand Down Expand Up @@ -166,6 +168,9 @@ final class DeepLinkController {
case .chatSendCash(let conversationID):
return action(.chatSendCash(conversationID))

case .chatMessage(let conversationID, let messageID):
return action(.chatMessage(conversationID, messageID))

case .tip(let userID):
return action(.tip(userID))

Expand Down Expand Up @@ -202,6 +207,9 @@ struct DeepLinkAction {

/// Where the URL that produced this action entered the app.
var origin: DeepLinkOrigin = .link

/// The chat the link was tapped in, or nil when it came from outside a chat.
var sourceChat: ConversationID?

private let sessionAuthenticator: SessionAuthenticator

Expand All @@ -221,7 +229,14 @@ struct DeepLinkAction {
///
/// `GetGroupChatFeed` carries only the groups the user has joined, so for a group they have not
/// joined this link is the only way in — and the screen it lands on is the one that offers the join.
private static func routeChat(_ conversationID: ConversationID, origin: DeepLinkOrigin, in container: SessionContainer) async {
///
/// `landing` names the message a message link points at; the transcript opens on it.
private static func routeChat(
_ conversationID: ConversationID,
landing: MessageLanding? = nil,
origin: DeepLinkOrigin,
in container: SessionContainer
) async {
let conversation = await container.conversationController.hydratedConversation(withID: conversationID)

let isPrivateOutsider = conversation.map {
Expand All @@ -230,7 +245,8 @@ struct DeepLinkAction {
guard let destination = chatDestination(
for: conversation?.type,
isPrivateOutsider: isPrivateOutsider,
conversationID: conversationID
conversationID: conversationID,
landing: landing
) else {
logger.info("Ignoring non-tip chat deeplink", metadata: [
"conversationID": "\(conversationID)",
Expand All @@ -244,24 +260,36 @@ struct DeepLinkAction {
Analytics.groupInviteFollowed(source: source)
}

container.appRouter.navigate(to: destination)
// A message link tapped in another chat goes on top of it, so the back chip and the back
// chevron return to that chat where the reader left it.
let router = container.appRouter
if case .chatMessage(let id, var landing) = destination, landing.returnTo != nil, router.canNavigateOver(destination) {
landing.isOverReturnChat = true
router.push(.chatMessage(id, landing))
} else {
router.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. The
/// exception is a private group the viewer isn't in, which has no chat they can read; it opens
/// the profile, where they ask to join.
/// the profile, where they ask to join, and a message link into it lands there too.
static func chatDestination(
for type: ConversationType?,
isPrivateOutsider: Bool = false,
conversationID: ConversationID
conversationID: ConversationID,
landing: MessageLanding? = nil
) -> AppRouter.Destination? {
switch type {
case .group where isPrivateOutsider:
return .chatProfile(conversationID, origin: .featuredGroup)
case .tipDm, .group:
if let landing {
return .chatMessage(conversationID, landing)
}
return .tipConversation(conversationID)
case .contactDm, nil:
return nil
Expand Down Expand Up @@ -419,6 +447,19 @@ struct DeepLinkAction {
await Self.routeChat(conversationID, origin: origin, in: container)
}

case .chatMessage(let conversationID, let messageID):
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// A link back into the chat it was tapped in has nothing to return to.
let returnTo = sourceChat == conversationID ? nil : sourceChat
await Self.routeChat(
conversationID,
landing: MessageLanding(messageID: messageID, returnTo: returnTo),
origin: origin,
in: container
)
}

case .chatSendCash(let conversationID):
if let container = sessionAuthenticator.loggedInContainer {
// Only tip DMs take the Send Cash action; contact/phone sends are no longer
Expand Down Expand Up @@ -495,6 +536,8 @@ extension DeepLinkAction {
case currencyInfo(PublicKey)
case chat(ConversationID)
case chatSendCash(ConversationID)
/// One message in a chat: the chat, opened on that message.
case chatMessage(ConversationID, MessageID)
/// The tipcard for the legacy `/tip/<userId>` link.
case tip(UserID)
/// The profile a `flipcash.com/<userId>` or `flipcash.com/<handle>` link names.
Expand All @@ -516,6 +559,7 @@ extension DeepLinkAction.Kind {
case .currencyInfo: "TokenInfo"
case .chat: "Chat"
case .chatSendCash: "ChatSendCash"
case .chatMessage: "ChatMessage"
case .tip: "Tip"
case .profile: "Profile"
case .wallet: "Wallet"
Expand Down
8 changes: 8 additions & 0 deletions Flipcash/Core/Controllers/Deep Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ nonisolated extension Route {
case token(PublicKey)
case chat(ConversationID)
case chatSendCash(ConversationID)
/// One message in a chat — `/chat/{chatId}/m/{seq}`, where `seq` is the message's
/// per-chat id. Opens the chat on that message.
case chatMessage(ConversationID, MessageID)
/// The older tipcard link, `/tip/<userId>`, which stays parseable for
/// links already shared and still opens the tipcard.
case tip(UserID)
Expand Down Expand Up @@ -210,6 +213,11 @@ nonisolated extension Route {
else {
return nil
}
// `/chat/{id}/m/{seq}` names one message. A tail that isn't a sequence number
// opens the chat, the same as a build that predates message links.
if components.count > 3, components[2] == "m", let seq = UInt64(components[3]) {
return .chatMessage(id, MessageID(value: seq))
}
// `/chat/{id}/send` opens the Send Cash sheet over the chat.
if components.count > 2, components[2] == "send" {
return .chatSendCash(id)
Expand Down
7 changes: 6 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ extension AppRouter {
/// trace shows "post-tip, keyboard up" distinctly, and so the ordinary
/// tip-list / push-notification opens stay keyboard-closed untouched.
case tipConversationWithKeyboard(ConversationID)
/// Same screen as `tipConversation`, opened on one message from a message link. A sibling
/// case for the same reason as `tipConversationWithKeyboard`: the ordinary opens stay untouched.
case chatMessage(ConversationID, MessageLanding)
/// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face;
/// hosts the Block action. `origin` decides whether it offers a way into the DM.
case userProfile(UserID, origin: UserProfileOrigin)
Expand Down Expand Up @@ -165,7 +168,7 @@ extension AppRouter {
.settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw:
return .you
case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup,
.tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile,
.tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile,
.editGroup, .editGroupName, .editGroupPicture, .editGroupCover,
.editGroupDescription, .archivedChats, .requests, .joinRequests, .groupMembers:
return .tips
Expand Down Expand Up @@ -221,6 +224,7 @@ extension AppRouter {
case .newPrivateGroup: "newPrivateGroup"
case .tipConversation: "tipConversation"
case .tipConversationWithKeyboard: "tipConversationWithKeyboard"
case .chatMessage: "chatMessage"
case .userProfile: "userProfile"
case .chatProfile: "chatProfile"
case .editGroup: "editGroup"
Expand Down Expand Up @@ -254,6 +258,7 @@ extension AppRouter {
return activity.id.base58
case .tipConversation(let conversationID),
.tipConversationWithKeyboard(let conversationID),
.chatMessage(let conversationID, _),
.chatProfile(let conversationID, _),
.editGroup(let conversationID),
.editGroupName(let conversationID),
Expand Down
5 changes: 5 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ struct DestinationView: View {
ConversationScreen(context: .existing(conversationID), openKeyboard: true)
.id(conversationID)

case .chatMessage(let conversationID, let landing):
// A message link: same screen, opened on the linked message.
ConversationScreen(context: .existing(conversationID), landing: landing)
.id(conversationID)

case .userProfile(let userID, let origin):
UserProfileScreen(userID: userID, origin: origin)
.id(userID)
Expand Down
8 changes: 7 additions & 1 deletion Flipcash/Core/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -576,13 +576,19 @@ final class AppRouter {
/// destination is pushed onto it rather than replacing its path, so backing out returns to the
/// screen the user was on when the link was followed.
func navigateOver(_ destination: Destination) {
guard presentedSheets.isEmpty, activeTabStack == destination.owningStack else {
guard canNavigateOver(destination) else {
navigate(to: destination)
return
}
push(destination)
}

/// Whether `navigateOver(_:)` would push `destination` onto the visible surface rather than
/// replace its stack's path.
func canNavigateOver(_ destination: Destination) -> Bool {
presentedSheets.isEmpty && activeTabStack == destination.owningStack
}

/// Surfaces the wallet at its root — where a grabbed deposit lands after
/// "Put in Wallet".
///
Expand Down
15 changes: 15 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in }
/// Room the transcript reserves under the navigation bar for the join-requests anchor.
var transcriptTopInset: CGFloat = 0
/// The latest request to land on a message, from a message link; each new token scrolls once.
var messageJump: MessageJump? = nil

func makeUIViewController(context: Context) -> ChatScreenViewController {
let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator))
Expand Down Expand Up @@ -190,6 +192,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onMentionRoomChange = { [mentions] room in mentions?.room = room }
screen.onMessagesSeen = onMessagesSeen
screen.reportsReads = reportsReads
perform(messageJump, on: screen, coordinator: context.coordinator)
screen.update(items: items)
context.coordinator.barHost = barHost
context.coordinator.screen = screen
Expand Down Expand Up @@ -257,13 +260,23 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
let lastIsOwnMessage = if case .message(let message) = newLastMessage { message.sender == .me } else { false }
let appendedOwn = newLastMessageID != context.coordinator.lastMessageID && lastIsOwnMessage
let wasAtBottom = screen.isTranscriptAtBottom
// Before the items land, so a row still outside the window is recorded as the pending
// target and the update that brings it in positions on it instead of at the bottom.
perform(messageJump, on: screen, coordinator: context.coordinator)
screen.update(items: items)
if appendedOwn, !wasAtBottom {
screen.scrollToBottom(animated: true)
}
context.coordinator.lastMessageID = newLastMessageID
}

/// Scrolls to `jump`'s message once per token.
private func perform(_ jump: MessageJump?, on screen: ChatScreenViewController, coordinator: Coordinator) {
guard let jump, jump.token != coordinator.lastJumpToken else { return }
coordinator.lastJumpToken = jump.token
screen.scrollToMessage(id: jump.stableID)
}

/// Wraps the action handler so the screen follows the action. The menu dismissed the keyboard
/// to present itself and the composer can't drive it back on its own — a hosted SwiftUI
/// `@FocusState` doesn't make the field first responder — so only the screen can raise it for an
Expand Down Expand Up @@ -368,6 +381,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
let overlay: AttachKeyboardOverlay
/// What the attach menu and its cards hand back, as of the latest render.
var attachActions = AttachActions()
/// The last message jump performed, so a re-render doesn't scroll again.
var lastJumpToken: Int?

init(model: ConversationBarModel, composer: ComposerModel) {
self.model = model
Expand Down
Loading