feat: handle message links

This commit is contained in:
infi 2026-07-25 02:38:37 +02:00
parent 0d414d48aa
commit 4df95b8052
11 changed files with 220 additions and 15 deletions

View File

@ -89,6 +89,20 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="stoat.chat" />
<data android:pathPattern="/channel/.........................." />
<data android:pathPattern="/channel/........................../.........................." />
<data android:pathPattern="/server/.........................." />
<data android:pathPattern="/server/........................../channel/.........................." />
<data android:pathPattern="/server/........................../channel/........................../.........................." />
</intent-filter>
</activity>
<activity
@ -214,4 +228,4 @@
</application>
</manifest>
</manifest>

View File

@ -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)

View File

@ -1,7 +1,8 @@
package chat.stoat.c2dm
import chat.stoat.internals.StoatWebLink
import kotlinx.coroutines.flow.MutableStateFlow
object NotificationDeepLink {
val pendingChannelId = MutableStateFlow<String?>(null)
val pendingNavigation = MutableStateFlow<StoatWebLink?>(null)
}

View File

@ -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()

View File

@ -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<KVStorage?>(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)
}
}
) {

View File

@ -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)

View File

@ -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)
}

View File

@ -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),

View File

@ -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<String?>(null)
var pendingMessageJump by mutableStateOf<ChannelMessageJump?>(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<String>(
CHANNEL_MESSAGE_JUMP_CHANNEL_KEY
@ -1062,6 +1119,8 @@ fun ChannelNavigator(
currentTopEntry?.savedStateHandle?.get<String>(
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<String>(
CHANNEL_MESSAGE_JUMP_CHANNEL_KEY
)

View File

@ -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()) {

View File

@ -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"
const val STOAT_CHANGELOG = "https://changelog.stoat.chat"