Skip to content

Commit ca523f9

Browse files
committed
feat: add jump-to-message with highlight animation
Clicking a message link (e.g. #channel/messageId) navigates to the target channel and scrolls to the referenced message with a pulsing highlight animation. Components: - ActionChannel: SwitchChannelToMessage action for message link routing - MarkdownText/JBMRenderer: Parse internal message links, resolve pretty display text (e.g. "# General"), navigate on click - ChatRouterScreen: Route SwitchChannelToMessage to channel view with pending scroll state - ChannelScreen: Scroll-to-item with viewport centering, pulse highlight overlay using drawWithContent (3 pulses, 3s hold, fade) - ChannelScreenViewModel: jumpToMessage() loads messages around target, switchChannel() accepts optional messageId Signed-off-by: sanasol <mail@sanasol.ws>
1 parent 4015513 commit ca523f9

File tree

6 files changed

+428
-49
lines changed

6 files changed

+428
-49
lines changed

app/src/main/java/chat/stoat/callbacks/ActionChannel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.channels.Channel
66
sealed class Action {
77
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
88
data class SwitchChannel(val channelId: String) : Action()
9+
data class SwitchChannelToMessage(val channelId: String, val messageId: String) : Action()
910
data class LinkInfo(val url: String) : Action()
1011
data class EmoteInfo(val emoteId: String) : Action()
1112
data class MessageReactionInfo(val messageId: String, val emoji: String) : Action()

app/src/main/java/chat/stoat/composables/markdown/MarkdownText.kt

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.core.net.toUri
3636
import chat.stoat.R
3737
import chat.stoat.activities.InviteActivity
3838
import chat.stoat.api.STOAT_FILES
39+
import chat.stoat.api.STOAT_WEB_APP
3940
import chat.stoat.api.StoatAPI
4041
import chat.stoat.api.routes.custom.fetchEmoji
4142
import chat.stoat.core.model.schemas.isInviteUri
@@ -44,6 +45,7 @@ import chat.stoat.callbacks.ActionChannel
4445
import chat.stoat.composables.generic.RemoteImage
4546
import chat.stoat.composables.utils.detectTapGesturesConditionalConsume
4647
import chat.stoat.internals.resolveTimestamp
48+
import chat.stoat.markdown.jbm.resolveInternalLinkText
4749
import chat.stoat.ndk.AstNode
4850
import chat.stoat.ui.theme.FragmentMono
4951
import kotlinx.coroutines.launch
@@ -192,17 +194,29 @@ fun annotateText(node: AstNode): AnnotatedString {
192194
} catch (e: Exception) {
193195
// no-op
194196
}
197+
val prettyText = resolveInternalLinkText(url.value)
195198
pushStringAnnotation(
196199
tag = Annotations.URL.tag,
197200
annotation = url.value
198201
)
199-
pushStyle(
200-
LocalTextStyle.current.toSpanStyle()
201-
.copy(
202-
color = MaterialTheme.colorScheme.primary
203-
)
204-
)
205-
append(url.value)
202+
if (prettyText != null) {
203+
pushStyle(
204+
LocalTextStyle.current.toSpanStyle()
205+
.copy(
206+
color = MaterialTheme.colorScheme.primary,
207+
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
208+
)
209+
)
210+
append(prettyText)
211+
} else {
212+
pushStyle(
213+
LocalTextStyle.current.toSpanStyle()
214+
.copy(
215+
color = MaterialTheme.colorScheme.primary
216+
)
217+
)
218+
append(url.value)
219+
}
206220
pop()
207221
pop()
208222
lastIndex = url.range.last + 1
@@ -236,17 +250,30 @@ fun annotateText(node: AstNode): AnnotatedString {
236250
}
237251

238252
"link" -> {
253+
val linkUrl = node.url ?: ""
254+
val prettyText = resolveInternalLinkText(linkUrl)
239255
pushStringAnnotation(
240256
tag = Annotations.URL.tag,
241-
annotation = node.url ?: ""
257+
annotation = linkUrl
242258
)
243-
pushStyle(
244-
LocalTextStyle.current.toSpanStyle()
245-
.copy(
246-
color = MaterialTheme.colorScheme.primary
247-
)
248-
)
249-
node.children?.forEach { append(annotateText(it)) }
259+
if (prettyText != null) {
260+
pushStyle(
261+
LocalTextStyle.current.toSpanStyle()
262+
.copy(
263+
color = MaterialTheme.colorScheme.primary,
264+
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
265+
)
266+
)
267+
append(prettyText)
268+
} else {
269+
pushStyle(
270+
LocalTextStyle.current.toSpanStyle()
271+
.copy(
272+
color = MaterialTheme.colorScheme.primary
273+
)
274+
)
275+
node.children?.forEach { append(annotateText(it)) }
276+
}
250277
pop()
251278
pop()
252279
}
@@ -319,6 +346,23 @@ fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
319346
}
320347
return@handler true
321348
}
349+
// Handle internal channel/message links
350+
if (uri.host == "chat.sanhost.net" || url.startsWith(STOAT_WEB_APP)) {
351+
val path = uri.path ?: ""
352+
val channelMatch = Regex("/(?:server/[A-Z0-9]+/)?channel/([A-Z0-9]+)(?:/([A-Z0-9]+))?").find(path)
353+
if (channelMatch != null) {
354+
val channelId = channelMatch.groupValues[1]
355+
val messageId = channelMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() }
356+
scope.launch {
357+
if (messageId != null) {
358+
ActionChannel.send(Action.SwitchChannelToMessage(channelId, messageId))
359+
} else {
360+
ActionChannel.send(Action.SwitchChannel(channelId))
361+
}
362+
}
363+
return@handler true
364+
}
365+
}
322366
} catch (e: Exception) {
323367
// no-op
324368
}

app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import chat.stoat.api.internals.isUlid
8181
import chat.stoat.api.routes.custom.fetchEmoji
8282
import chat.stoat.core.model.schemas.isInviteUri
8383
import chat.stoat.api.settings.LoadedSettings
84+
import chat.stoat.api.STOAT_WEB_APP
8485
import chat.stoat.callbacks.Action
8586
import chat.stoat.callbacks.ActionChannel
8687
import chat.stoat.composables.generic.RemoteImage
@@ -119,6 +120,26 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
119120

120121
object JBMRegularExpressions {
121122
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
123+
val InternalLink = Regex("/(?:server/[A-Z0-9]+/)?channel/([A-Z0-9]+)(?:/([A-Z0-9]+))?")
124+
}
125+
126+
/**
127+
* Resolve an internal URL to a pretty display string like "# General > 💬"
128+
* Returns null if not an internal URL.
129+
*/
130+
fun resolveInternalLinkText(url: String): String? {
131+
try {
132+
val uri = url.toUri()
133+
if (uri.host != "chat.sanhost.net" && !url.startsWith(STOAT_WEB_APP)) return null
134+
val path = uri.path ?: return null
135+
val match = JBMRegularExpressions.InternalLink.find(path) ?: return null
136+
val channelId = match.groupValues[1]
137+
val messageId = match.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() }
138+
val channelName = StoatAPI.channelCache[channelId]?.name ?: "unknown"
139+
return if (messageId != null) " # $channelName \u203A \uD83D\uDCAC " else " # $channelName "
140+
} catch (e: Exception) {
141+
return null
142+
}
122143
}
123144

124145
data class JBMColors(
@@ -431,31 +452,56 @@ private fun annotateText(
431452
node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
432453
?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK }
433454

455+
val linkUrl = linkDestinationChild?.getTextInNode(sourceText).toString()
456+
.removeSurrounding("<", ">")
457+
val prettyText = resolveInternalLinkText(linkUrl)
458+
434459
pushStringAnnotation(
435460
tag = JBMAnnotations.URL.tag,
436-
annotation = linkDestinationChild?.getTextInNode(sourceText).toString()
437-
.removeSurrounding("<", ">")
461+
annotation = linkUrl
438462
)
439-
pushStyle(SpanStyle(color = state.colors.clickable))
440-
linkTextChild?.children
441-
?.drop(1) // l-bracket
442-
?.dropLast(1) // r-bracket
443-
?.forEach {
444-
append(annotateText(state, it))
445-
}
463+
if (prettyText != null) {
464+
pushStyle(
465+
SpanStyle(
466+
color = state.colors.clickable,
467+
background = state.colors.clickableBackground
468+
)
469+
)
470+
append(prettyText)
471+
} else {
472+
pushStyle(SpanStyle(color = state.colors.clickable))
473+
linkTextChild?.children
474+
?.drop(1) // l-bracket
475+
?.dropLast(1) // r-bracket
476+
?.forEach {
477+
append(annotateText(state, it))
478+
}
479+
}
446480
pop()
447481
pop()
448482
}
449483

450484
GFMTokenTypes.GFM_AUTOLINK,
451485
MarkdownTokenTypes.AUTOLINK -> {
486+
val urlText = node.getTextInNode(sourceText).toString()
487+
.removeSurrounding("<", ">")
488+
val prettyText = resolveInternalLinkText(urlText)
452489
pushStringAnnotation(
453490
tag = JBMAnnotations.URL.tag,
454-
annotation = node.getTextInNode(sourceText).toString()
455-
.removeSurrounding("<", ">")
491+
annotation = urlText
456492
)
457-
pushStyle(SpanStyle(color = state.colors.clickable))
458-
append(node.getTextInNode(sourceText))
493+
if (prettyText != null) {
494+
pushStyle(
495+
SpanStyle(
496+
color = state.colors.clickable,
497+
background = state.colors.clickableBackground
498+
)
499+
)
500+
append(prettyText)
501+
} else {
502+
pushStyle(SpanStyle(color = state.colors.clickable))
503+
append(node.getTextInNode(sourceText))
504+
}
459505
pop()
460506
pop()
461507
}
@@ -563,6 +609,23 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
563609
}
564610
return@handler true
565611
}
612+
// Handle internal channel/message links
613+
if (uri.host == "chat.sanhost.net" || item.startsWith(STOAT_WEB_APP)) {
614+
val path = uri.path ?: ""
615+
val channelMatch = Regex("/(?:server/[A-Z0-9]+/)?channel/([A-Z0-9]+)(?:/([A-Z0-9]+))?").find(path)
616+
if (channelMatch != null) {
617+
val channelId = channelMatch.groupValues[1]
618+
val messageId = channelMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() }
619+
scope.launch {
620+
if (messageId != null) {
621+
ActionChannel.send(Action.SwitchChannelToMessage(channelId, messageId))
622+
} else {
623+
ActionChannel.send(Action.SwitchChannel(channelId))
624+
}
625+
}
626+
return@handler true
627+
}
628+
}
566629
} catch (e: Exception) {
567630
// no-op
568631
}

0 commit comments

Comments
 (0)