feat: handle message links
This commit is contained in:
parent
0d414d48aa
commit
4df95b8052
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue