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"