From 4df95b8052f15eaa8935050afbc6b96b5edc76ac Mon Sep 17 00:00:00 2001 From: infi Date: Sat, 25 Jul 2026 02:38:37 +0200 Subject: [PATCH] feat: handle message links --- app/src/main/AndroidManifest.xml | 16 +++- .../chat/stoat/activities/MainActivity.kt | 27 ++++++- .../chat/stoat/c2dm/NotificationDeepLink.kt | 3 +- .../chat/stoat/callbacks/ActionChannel.kt | 2 + .../chat/stoat/composables/chat/Message.kt | 17 ++++- .../markdown/prose/ChatMarkdown.kt | 9 +++ .../java/chat/stoat/internals/StoatWebLink.kt | 73 +++++++++++++++++++ .../stoat/screens/chat/ChannelSearchScreen.kt | 5 ++ .../stoat/screens/chat/ChatRouterScreen.kt | 70 ++++++++++++++++-- .../chat/stoat/sheets/MessageContextSheet.kt | 10 ++- .../chat/stoat/core/model/data/Constants.kt | 3 +- 11 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/chat/stoat/internals/StoatWebLink.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27e304d2..d2ca13e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -89,6 +89,20 @@ + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index ebeb0041..d55a93b4 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -1,6 +1,7 @@ package chat.stoat.activities import android.content.Context +import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Bundle @@ -89,6 +90,8 @@ import chat.stoat.c2dm.NotificationDeepLink import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.composables.voice.VoiceSheet +import chat.stoat.internals.StoatWebLink +import chat.stoat.internals.toStoatWebLinkOrNull import chat.stoat.voice.VoiceCallManager import chat.stoat.core.model.schemas.HealthNotice import chat.stoat.material.EasingTokens @@ -350,6 +353,28 @@ class MainActivity : AppCompatActivity() { window.statusBarColor = Color.Transparent.toArgb() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleNavigationIntent(intent) + } + + private fun handleNavigationIntent(intent: Intent) { + val webLink = intent.data?.toStoatWebLinkOrNull() + if (webLink != null) { + NotificationDeepLink.pendingNavigation.value = webLink + return + } + + val channelId = intent.getStringExtra("channelId") ?: return + val messageId = intent.getStringExtra("messageId") + NotificationDeepLink.pendingNavigation.value = if (messageId != null) { + StoatWebLink.Message(channelId, messageId) + } else { + StoatWebLink.Channel(channelId) + } + } + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -365,7 +390,7 @@ class MainActivity : AppCompatActivity() { StoatAPI.hydrateFromPersistentCache() - intent.getStringExtra("channelId")?.let { NotificationDeepLink.pendingChannelId.value = it } + handleNavigationIntent(intent) setContent { val windowSizeClass = calculateWindowSizeClass(this) diff --git a/app/src/main/java/chat/stoat/c2dm/NotificationDeepLink.kt b/app/src/main/java/chat/stoat/c2dm/NotificationDeepLink.kt index 43ba9ec5..add5d217 100644 --- a/app/src/main/java/chat/stoat/c2dm/NotificationDeepLink.kt +++ b/app/src/main/java/chat/stoat/c2dm/NotificationDeepLink.kt @@ -1,7 +1,8 @@ package chat.stoat.c2dm +import chat.stoat.internals.StoatWebLink import kotlinx.coroutines.flow.MutableStateFlow object NotificationDeepLink { - val pendingChannelId = MutableStateFlow(null) + val pendingNavigation = MutableStateFlow(null) } diff --git a/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt b/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt index 2354f92a..f2849fd3 100644 --- a/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/stoat/callbacks/ActionChannel.kt @@ -5,7 +5,9 @@ import kotlinx.coroutines.channels.Channel sealed class Action { data class OpenUserSheet(val userId: String, val serverId: String?) : Action() + data class SwitchServer(val serverId: String) : Action() data class SwitchChannel(val channelId: String) : Action() + data class JumpToMessage(val channelId: String, val messageId: String) : Action() data class LinkInfo(val url: String) : Action() data class EmoteInfo(val emoteId: String) : Action() data class MessageReactionInfo(val messageId: String, val emoji: String) : Action() diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index c248d60c..e204b6b6 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import chat.stoat.R import chat.stoat.activities.media.ImageViewActivity import chat.stoat.activities.media.VideoViewActivity @@ -87,6 +88,8 @@ import chat.stoat.internals.text.Gigamoji import chat.stoat.internals.text.GigamojiState import chat.stoat.internals.text.MessageProcessor import chat.stoat.internals.text.stripPUAChars +import chat.stoat.internals.toNavigationAction +import chat.stoat.internals.toStoatWebLinkOrNull import chat.stoat.persistence.KVStorage import com.mikepenz.markdown.model.State import kotlinx.coroutines.launch @@ -227,6 +230,16 @@ fun Message( val context = LocalContext.current val scope = rememberCoroutineScope() + val openMessageLinkOrBrowser: (String) -> Unit = { url -> + val stoatLink = url.toUri().toStoatWebLinkOrNull() + if (stoatLink != null) { + scope.launch { + ActionChannel.send(stoatLink.toNavigationAction()) + } + } else { + viewUrlInBrowser(context, url) + } + } var kv by remember { mutableStateOf(null) } var showUsernameDiscriminator by remember { mutableStateOf(false) } var ignoreServerAvatar by remember { mutableStateOf(false) } @@ -545,7 +558,7 @@ fun Message( embed = embed, serverId = StoatAPI.channelCache[message.channel]?.server, onLinkClick = { - viewUrlInBrowser(context, it) + openMessageLinkOrBrowser(it) }) Spacer(modifier = Modifier.height(8.dp)) } @@ -557,7 +570,7 @@ fun Message( .clip(MaterialTheme.shapes.medium) .clickable { embed.url?.let { - viewUrlInBrowser(context, it) + openMessageLinkOrBrowser(it) } } ) { diff --git a/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt index f47a3d64..a8f1cdb3 100644 --- a/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt +++ b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt @@ -58,6 +58,8 @@ import chat.stoat.core.model.data.STOAT_FILES import chat.stoat.core.model.schemas.User import chat.stoat.core.model.schemas.isInviteUri import chat.stoat.internals.resolveTimestamp +import chat.stoat.internals.toNavigationAction +import chat.stoat.internals.toStoatWebLinkOrNull import chat.stoat.markdown.CHANNEL_MENTION_ELEMENT_TYPE import chat.stoat.markdown.CUSTOM_EMOTE_ELEMENT_TYPE import chat.stoat.markdown.MASS_MENTION_ELEMENT_TYPE @@ -399,6 +401,13 @@ fun ChatMarkdown( else -> { try { val parsed = uri.toUri() + val stoatLink = parsed.toStoatWebLinkOrNull() + if (stoatLink != null) { + scope.launch { + ActionChannel.send(stoatLink.toNavigationAction()) + } + return + } if (parsed.isInviteUri()) { context.startActivity( Intent(context, InviteActivity::class.java) diff --git a/app/src/main/java/chat/stoat/internals/StoatWebLink.kt b/app/src/main/java/chat/stoat/internals/StoatWebLink.kt new file mode 100644 index 00000000..6b92bf92 --- /dev/null +++ b/app/src/main/java/chat/stoat/internals/StoatWebLink.kt @@ -0,0 +1,73 @@ +package chat.stoat.internals + +import android.net.Uri +import androidx.core.net.toUri +import chat.stoat.api.internals.isUlid +import chat.stoat.callbacks.Action +import chat.stoat.core.model.data.STOAT_BETA_WEB_APP +import chat.stoat.core.model.data.STOAT_WEB_APP + +sealed interface StoatWebLink { + data class Server(val serverId: String) : StoatWebLink + data class Channel(val channelId: String) : StoatWebLink + data class Message(val channelId: String, val messageId: String) : StoatWebLink +} + +fun Uri.toStoatWebLinkOrNull(): StoatWebLink? { + if (scheme != "https") return null + + val validHosts = setOf( + STOAT_WEB_APP.toUri().host, + STOAT_BETA_WEB_APP.toUri().host, + ) + if (host !in validHosts) return null + + val segments = pathSegments + + return when { + segments.size == 2 && + segments[0] == "channel" && + segments[1].isUlid() -> + StoatWebLink.Channel(channelId = segments[1]) + + segments.size == 3 && + segments[0] == "channel" && + segments[1].isUlid() && + segments[2].isUlid() -> + StoatWebLink.Message( + channelId = segments[1], + messageId = segments[2], + ) + + segments.size == 2 && + segments[0] == "server" && + segments[1].isUlid() -> + StoatWebLink.Server(serverId = segments[1]) + + segments.size == 4 && + segments[0] == "server" && + segments[1].isUlid() && + segments[2] == "channel" && + segments[3].isUlid() -> + StoatWebLink.Channel(channelId = segments[3]) + + segments.size == 5 && + segments[0] == "server" && + segments[1].isUlid() && + segments[2] == "channel" && + segments[3].isUlid() && + segments[4].isUlid() -> + StoatWebLink.Message( + channelId = segments[3], + messageId = segments[4], + ) + + else -> null + } +} + +fun StoatWebLink.toNavigationAction(): Action = when (this) { + is StoatWebLink.Server -> Action.SwitchServer(serverId) + is StoatWebLink.Channel -> Action.SwitchChannel(channelId) + is StoatWebLink.Message -> Action.JumpToMessage(channelId, messageId) +} diff --git a/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt index 31896129..86e42a7b 100644 --- a/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/ChannelSearchScreen.kt @@ -61,6 +61,11 @@ private const val SEARCH_DEBOUNCE_MS = 350L const val CHANNEL_MESSAGE_JUMP_CHANNEL_KEY = "channelMessageJumpChannel" const val CHANNEL_MESSAGE_JUMP_MESSAGE_KEY = "channelMessageJumpMessage" +data class ChannelMessageJump( + val channelId: String, + val messageId: String, +) + enum class SearchSort(val apiValue: String, val label: Int) { RELEVANCE("Relevance", R.string.channel_search_sort_relevance), LATEST("Latest", R.string.channel_search_sort_latest), diff --git a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt index f4d580c7..1b49a11f 100644 --- a/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/ChatRouterScreen.kt @@ -85,6 +85,7 @@ import chat.stoat.composables.screens.chat.drawer.ChannelSideDrawer import chat.stoat.core.model.schemas.ReleaseNotesSettings import chat.stoat.dialogs.NotificationRationaleDialog import chat.stoat.c2dm.NotificationDeepLink +import chat.stoat.internals.StoatWebLink import chat.stoat.internals.extensions.zero import chat.stoat.persistence.KVStorage import chat.stoat.screens.chat.dialogs.safety.ReportMessageDialog @@ -162,6 +163,8 @@ class ChatRouterViewModel( var showEarlyAccessSpark by mutableStateOf(false) var showSwipeToReplySpark by mutableStateOf(false) var showChangelogScreenForId by mutableStateOf(null) + var pendingMessageJump by mutableStateOf(null) + private set private var changelogCheckDone = false init { @@ -172,10 +175,9 @@ class ChatRouterViewModel( kvStorage.set("selfAvatarUrl", user.avatar?.id?.let { "$STOAT_FILES/avatars/$it" } ?: "") } - val pendingChannel = NotificationDeepLink.pendingChannelId.value - if (pendingChannel != null) { - NotificationDeepLink.pendingChannelId.value = null - setSaveDestination(ChatRouterDestination.Channel(pendingChannel)) + val pendingNavigation = NotificationDeepLink.pendingNavigation.value + if (pendingNavigation != null) { + consumePendingNavigation(pendingNavigation) } else { val current = kvStorage.get("currentDestination") setSaveDestination(ChatRouterDestination.fromString(current ?: "")) @@ -200,9 +202,37 @@ class ChatRouterViewModel( if (!hasNotificationPermission && !rejectedPush) { showNotificationRationale = true } + + NotificationDeepLink.pendingNavigation.collect { navigation -> + if (navigation != null) consumePendingNavigation(navigation) + } } } + private fun consumePendingNavigation(navigation: StoatWebLink) { + if (NotificationDeepLink.pendingNavigation.value == navigation) { + NotificationDeepLink.pendingNavigation.value = null + } + + when (navigation) { + is StoatWebLink.Server -> navigateToServer(navigation.serverId) + is StoatWebLink.Channel -> + setSaveDestination(ChatRouterDestination.Channel(navigation.channelId)) + + is StoatWebLink.Message -> + requestMessageJump(navigation.channelId, navigation.messageId) + } + } + + fun requestMessageJump(channelId: String, messageId: String) { + pendingMessageJump = ChannelMessageJump(channelId, messageId) + setSaveDestination(ChatRouterDestination.Channel(channelId)) + } + + fun consumeMessageJump(request: ChannelMessageJump) { + if (pendingMessageJump == request) pendingMessageJump = null + } + fun setSaveDestination(destination: ChatRouterDestination) { currentDestination = destination @@ -464,6 +494,10 @@ fun ChatRouterScreen( showUserContextSheet = true } + is Action.SwitchServer -> { + viewModel.navigateToServer(action.serverId) + } + is Action.SwitchChannel -> { val resolvedChannel = StoatAPI.channelCache[action.channelId] @@ -475,6 +509,20 @@ fun ChatRouterScreen( viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId)) } + is Action.JumpToMessage -> { + val resolvedChannel = StoatAPI.channelCache[action.channelId] + + if (resolvedChannel == null) { + showChannelUnavailableAlert = true + return@let + } + + viewModel.requestMessageJump( + channelId = action.channelId, + messageId = action.messageId, + ) + } + is Action.LinkInfo -> { linkInfoSheetUrl = action.url showLinkInfoSheet = true @@ -892,6 +940,8 @@ fun ChatRouterScreen( ChannelNavigator( dest = viewModel.currentDestination, topNav = topNav, + messageJump = viewModel.pendingMessageJump, + onMessageJumpConsumed = viewModel::consumeMessageJump, useDrawer = false, disableBackHandler = disableBackHandler, toggleDrawer = { @@ -940,6 +990,8 @@ fun ChatRouterScreen( ChannelNavigator( dest = viewModel.currentDestination, topNav = topNav, + messageJump = viewModel.pendingMessageJump, + onMessageJumpConsumed = viewModel::consumeMessageJump, useDrawer = true, disableBackHandler = disableBackHandler, toggleDrawer = { @@ -1020,6 +1072,8 @@ fun Sidebar( fun ChannelNavigator( dest: ChatRouterDestination, topNav: NavController, + messageJump: ChannelMessageJump? = null, + onMessageJumpConsumed: (ChannelMessageJump) -> Unit = {}, useDrawer: Boolean, toggleDrawer: () -> Unit, drawerState: DrawerState? = null, @@ -1054,6 +1108,9 @@ fun ChannelNavigator( } is ChatRouterDestination.Channel -> { + val routedMessageJump = messageJump?.takeIf { + it.channelId == dest.channelId + } val requestedChannelId = currentTopEntry?.savedStateHandle?.get( CHANNEL_MESSAGE_JUMP_CHANNEL_KEY @@ -1062,6 +1119,8 @@ fun ChannelNavigator( currentTopEntry?.savedStateHandle?.get( CHANNEL_MESSAGE_JUMP_MESSAGE_KEY )?.takeIf { requestedChannelId == dest.channelId } + val effectiveRequestedMessageId = + routedMessageJump?.messageId ?: requestedMessageId ChannelScreen( channelId = dest.channelId, @@ -1078,8 +1137,9 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, - requestedMessageId = requestedMessageId, + requestedMessageId = effectiveRequestedMessageId, onRequestedMessageConsumed = { + routedMessageJump?.let(onMessageJumpConsumed) currentTopEntry?.savedStateHandle?.remove( CHANNEL_MESSAGE_JUMP_CHANNEL_KEY ) diff --git a/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt b/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt index e726cb6c..a306c000 100644 --- a/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt +++ b/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt @@ -169,11 +169,13 @@ fun MessageContextSheet( return@SheetButton } - val server = StoatAPI.serverCache.values.find { server -> - server.channels?.contains(message.channel) ?: false + val serverId = StoatAPI.channelCache[message.channel]?.server + val messagePath = if (serverId != null) { + "/server/$serverId/channel/${message.channel}/${message.id}" + } else { + "/channel/${message.channel}/${message.id}" } - val messageLink = - "$STOAT_WEB_APP/server/${server?.id}/channel/${message.channel}/${message.id}" + val messageLink = "$STOAT_WEB_APP$messagePath" clipboardManager.setText(AnnotatedString(messageLink)) if (Platform.needsShowClipboardNotification()) { diff --git a/core/model/src/main/java/chat/stoat/core/model/data/Constants.kt b/core/model/src/main/java/chat/stoat/core/model/data/Constants.kt index 8a74767c..9c9a8b1c 100644 --- a/core/model/src/main/java/chat/stoat/core/model/data/Constants.kt +++ b/core/model/src/main/java/chat/stoat/core/model/data/Constants.kt @@ -6,6 +6,7 @@ const val STOAT_MARKETING = "https://stoat.chat" const val STOAT_FILES = "https://cdn.stoatusercontent.com" const val STOAT_PROXY = "https://proxy.stoatusercontent.com" const val STOAT_WEB_APP = "https://stoat.chat" +const val STOAT_BETA_WEB_APP = "https://beta.stoat.chat" const val STOAT_INVITES = "https://stt.gg" const val STOAT_WEBSOCKET = "wss://events.stoat.chat" -const val STOAT_CHANGELOG = "https://changelog.stoat.chat" \ No newline at end of file +const val STOAT_CHANGELOG = "https://changelog.stoat.chat"