feat: pinned messages view and system message

This commit is contained in:
infi 2026-03-07 23:53:34 +01:00
parent 2427f9e6d4
commit 058dcbcd50
8 changed files with 364 additions and 2 deletions

View File

@ -82,19 +82,20 @@ import chat.stoat.api.api
import chat.stoat.api.routes.microservices.geo.queryGeo import chat.stoat.api.routes.microservices.geo.queryGeo
import chat.stoat.api.routes.microservices.health.healthCheck import chat.stoat.api.routes.microservices.health.healthCheck
import chat.stoat.api.routes.onboard.needsOnboarding import chat.stoat.api.routes.onboard.needsOnboarding
import chat.stoat.core.model.schemas.HealthNotice
import chat.stoat.api.settings.Experiments import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.GeoStateProvider
import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.SyncedSettings import chat.stoat.api.settings.SyncedSettings
import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.core.model.schemas.HealthNotice
import chat.stoat.material.EasingTokens import chat.stoat.material.EasingTokens
import chat.stoat.ndk.NativeLibraries import chat.stoat.ndk.NativeLibraries
import chat.stoat.persistence.KVStorage import chat.stoat.persistence.KVStorage
import chat.stoat.screens.DefaultDestinationScreen import chat.stoat.screens.DefaultDestinationScreen
import chat.stoat.screens.about.AboutScreen import chat.stoat.screens.about.AboutScreen
import chat.stoat.screens.about.AttributionScreen import chat.stoat.screens.about.AttributionScreen
import chat.stoat.screens.chat.ChannelPinsScreen
import chat.stoat.screens.chat.ChatRouterScreen import chat.stoat.screens.chat.ChatRouterScreen
import chat.stoat.screens.chat.standalone.CatchUpScreen import chat.stoat.screens.chat.standalone.CatchUpScreen
import chat.stoat.screens.chat.views.channel.ChannelScreen import chat.stoat.screens.chat.views.channel.ChannelScreen
@ -733,6 +734,11 @@ fun AppEntrypoint(
ChannelSettingsPermissions(navController, channelId) ChannelSettingsPermissions(navController, channelId)
} }
composable("channel/{channelId}/pins") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelPinsScreen(navController, channelId)
}
composable("about") { AboutScreen(navController) } composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) } composable("about/oss") { AttributionScreen(navController) }

View File

@ -256,3 +256,83 @@ suspend fun patchChannel(
StoatAPI.channelCache[channelId] = channel StoatAPI.channelCache[channelId] = channel
} }
} }
suspend fun searchChannel(
channelId: String,
query: String? = null,
pinned: Boolean? = null,
includeUsers: Boolean? = null,
after: String? = null,
before: String? = null,
limit: Int? = null,
sort: String? = null
): MessagesInChannel {
check(
sort == null || sort in listOf(
"Relevance",
"Latest",
"Oldest"
)
) { "Sort must be one of Relevance, Latest, Oldest; failing that null" }
check(limit == null || (limit in 1..100)) { "Limit must be between 1 and 100; failing that null" }
check(query == null || pinned == null) { "One of query or pinned must be null" }
check(
pinned != null || (!query.isNullOrBlank() && query.length <= 64)
) { "Query must not be null when pinned is not null; Query must not be blank when pinned is not null; Query must be less than 65 characters when pinned is not null" }
val body = mutableMapOf<String, JsonElement>()
if (query != null) {
body["query"] = StoatJson.encodeToJsonElement(String.serializer(), query)
}
if (pinned != null) {
body["pinned"] = StoatJson.encodeToJsonElement(Boolean.serializer(), pinned)
}
if (includeUsers != null) {
body["include_users"] = StoatJson.encodeToJsonElement(Boolean.serializer(), includeUsers)
}
if (after != null) {
body["after"] = StoatJson.encodeToJsonElement(String.serializer(), after)
}
if (before != null) {
body["before"] = StoatJson.encodeToJsonElement(String.serializer(), before)
}
if (limit != null) {
body["limit"] = StoatJson.encodeToJsonElement(Int.serializer(), limit)
}
if (sort != null) {
body["sort"] = StoatJson.encodeToJsonElement(String.serializer(), sort)
}
val response = StoatHttp.post("/channels/$channelId/search".api()) {
contentType(ContentType.Application.Json)
setBody(
StoatJson.encodeToString(
MapSerializer(
String.serializer(),
JsonElement.serializer()
),
body
)
)
}
.bodyAsText()
if (includeUsers == true) {
return StoatJson.decodeFromString(
MessagesInChannel.serializer(),
response
)
} else {
val messages = StoatJson.decodeFromString(
ListSerializer(Message.serializer()),
response
)
return MessagesInChannel(
messages = messages,
users = emptyList(),
members = emptyList()
)
}
}

View File

@ -42,6 +42,8 @@ enum class SystemMessageType(val type: String) {
USER_KICKED("user_kicked"), USER_KICKED("user_kicked"),
USER_LEFT("user_left"), USER_LEFT("user_left"),
USER_JOINED("user_joined"), USER_JOINED("user_joined"),
MESSAGE_PINNED("message_pinned"),
MESSAGE_UNPINNED("message_unpinned"),
TEXT("text") TEXT("text")
} }
@ -172,6 +174,24 @@ fun SystemMessage(message: Message) {
) )
} }
SystemMessageType.MESSAGE_PINNED -> {
RichMarkdown(
stringResource(
R.string.system_message_message_pinned,
message.system!!.by.mention()
)
)
}
SystemMessageType.MESSAGE_UNPINNED -> {
RichMarkdown(
stringResource(
R.string.system_message_message_unpinned,
message.system!!.by.mention()
)
)
}
SystemMessageType.TEXT -> { SystemMessageType.TEXT -> {
message.system!!.content?.let { RichMarkdown(it) } message.system!!.content?.let { RichMarkdown(it) }
} }
@ -277,6 +297,24 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
) )
} }
SystemMessageType.MESSAGE_PINNED -> {
Icon(
painter = painterResource(R.drawable.ic_keep_24dp),
contentDescription = stringResource(R.string.system_message_message_pinned_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
)
}
SystemMessageType.MESSAGE_UNPINNED -> {
Icon(
painter = painterResource(R.drawable.ic_keep_off_24dp),
contentDescription = stringResource(R.string.system_message_message_unpinned_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
)
}
SystemMessageType.TEXT -> { SystemMessageType.TEXT -> {
Icon( Icon(
painter = painterResource(R.drawable.ic_info_24dp), painter = painterResource(R.drawable.ic_info_24dp),
@ -302,6 +340,8 @@ private fun shapeForType(type: SystemMessageType): Shape {
SystemMessageType.USER_KICKED -> MaterialShapes.SoftBurst SystemMessageType.USER_KICKED -> MaterialShapes.SoftBurst
SystemMessageType.USER_LEFT -> MaterialShapes.Cookie4Sided SystemMessageType.USER_LEFT -> MaterialShapes.Cookie4Sided
SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided
SystemMessageType.MESSAGE_PINNED -> MaterialShapes.Clover4Leaf
SystemMessageType.MESSAGE_UNPINNED -> MaterialShapes.Clover8Leaf
SystemMessageType.TEXT -> MaterialShapes.Square SystemMessageType.TEXT -> MaterialShapes.Square
}.toShape() }.toShape()
} }

View File

@ -0,0 +1,196 @@
package chat.stoat.screens.chat
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.channel.searchChannel
import chat.stoat.composables.chat.SystemMessage
import chat.stoat.core.model.schemas.Message
import chat.stoat.internals.extensions.zero
import kotlinx.coroutines.launch
class ChannelPinsScreenViewModel : ViewModel() {
val pinnedMessages = mutableStateListOf<Message>()
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
fun loadPins(channelId: String) {
pinnedMessages.clear()
isLoading = true
error = null
viewModelScope.launch {
try {
val response =
searchChannel(channelId, pinned = true, limit = 100, includeUsers = true)
response.users?.forEach { user ->
user.id?.let { id ->
StoatAPI.userCache.putIfAbsent(id, user)
}
}
response.members?.forEach { member ->
member.id?.let { memberId ->
if (!StoatAPI.members.hasMember(memberId.server, memberId.user)) {
StoatAPI.members.setMember(memberId.server, member)
}
}
}
pinnedMessages.addAll(response.messages ?: emptyList())
} catch (e: Exception) {
Log.e("ChannelPinsScreen", "Failed to load pinned messages", e)
error = e.message
} finally {
isLoading = false
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelPinsScreen(
navController: NavController,
channelId: String,
modifier: Modifier = Modifier,
viewModel: ChannelPinsScreenViewModel = viewModel()
) {
LaunchedEffect(channelId) {
viewModel.loadPins(channelId)
}
Scaffold(
topBar = {
Column {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
}
TopAppBar(
title = {
Text(
text = stringResource(R.string.pinned_messages),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_24dp),
contentDescription = stringResource(id = R.string.back)
)
}
}
)
}
},
contentWindowInsets = WindowInsets.zero
) { pv ->
Box(
modifier = Modifier
.padding(pv)
.fillMaxHeight()
) {
Crossfade(targetState = viewModel.isLoading, label = "pinsLoading") { loading ->
if (loading) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
} else if (viewModel.pinnedMessages.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = viewModel.error
?: stringResource(R.string.pinned_messages_empty),
color = if (viewModel.error != null) MaterialTheme.colorScheme.error else LocalContentColor.current,
modifier = Modifier.padding(horizontal = 64.dp),
textAlign = TextAlign.Center
)
if (viewModel.error != null) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = { viewModel.loadPins(channelId) }) {
Text(stringResource(R.string.tap_to_retry))
}
}
}
}
} else {
LazyColumn(contentPadding = WindowInsets.navigationBars.asPaddingValues()) {
items(
viewModel.pinnedMessages.size,
key = { i -> viewModel.pinnedMessages[i].id ?: i }) { i ->
val message = viewModel.pinnedMessages[i].copy(tail = false)
if (message.system != null) {
SystemMessage(message)
} else {
chat.stoat.composables.chat.Message(message = message)
}
}
}
}
}
}
}
}

View File

@ -639,6 +639,18 @@ fun ChannelScreen(
) )
} }
} }
},
actions = {
IconButton(onClick = {
scope.launch {
ActionChannel.send(Action.TopNavigate("channel/$channelId/pins"))
}
}) {
Icon(
painter = painterResource(R.drawable.ic_pinboard_24dp),
contentDescription = stringResource(id = R.string.pinned_messages_view)
)
}
} }
) )
} }
@ -669,7 +681,7 @@ fun ChannelScreen(
) { ) {
CircularProgressIndicator(modifier = Modifier.size(48.dp)) CircularProgressIndicator(modifier = Modifier.size(48.dp))
} }
} else if (ageGateUnlocked == true) { } else if (ageGateUnlocked) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(pv) .padding(pv)

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M680,120L680,200L640,200L640,527L560,447L560,200L400,200L400,287L313,200L280,167L280,167L280,120L680,120ZM480,920L440,880L440,640L240,640L240,560L320,480L320,434L56,168L112,112L848,848L790,904L526,640L520,640L520,880L480,920ZM354,560L446,560L402,516L400,514L354,560ZM480,367L480,367L480,367L480,367ZM402,516L402,516L402,516L402,516Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,880L220,840L220,680L80,680L80,600L140,494L140,400L80,400L80,320L440,320L440,400L380,400L380,494L440,600L440,680L300,680L300,840L260,880ZM480,800L480,720L800,720Q800,720 800,720Q800,720 800,720L800,240Q800,240 800,240Q800,240 800,240L80,240L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L480,800ZM172,600L348,600L300,516L300,400L220,400L220,516L172,600ZM260,600L260,600L260,600L260,600L260,600L260,600Z"/>
</vector>

View File

@ -277,6 +277,8 @@
<string name="system_message_user_kicked_alt">User kicked</string> <string name="system_message_user_kicked_alt">User kicked</string>
<string name="system_message_user_left_alt">User left</string> <string name="system_message_user_left_alt">User left</string>
<string name="system_message_user_joined_alt">User joined</string> <string name="system_message_user_joined_alt">User joined</string>
<string name="system_message_message_pinned_alt">Message pinned</string>
<string name="system_message_message_unpinned_alt">Message unpinned</string>
<string name="system_message_text_alt">System message</string> <string name="system_message_text_alt">System message</string>
<string name="system_message_ownership_changed">%1$s transferred ownership to %2$s</string> <string name="system_message_ownership_changed">%1$s transferred ownership to %2$s</string>
@ -289,6 +291,8 @@
<string name="system_message_user_kicked">%1$s has been kicked</string> <string name="system_message_user_kicked">%1$s has been kicked</string>
<string name="system_message_user_left">%1$s left</string> <string name="system_message_user_left">%1$s left</string>
<string name="system_message_user_joined">%1$s joined</string> <string name="system_message_user_joined">%1$s joined</string>
<string name="system_message_message_pinned">%1$s pinned a message to this channel</string>
<string name="system_message_message_unpinned">%1$s unpinned a message from this channel</string>
<string name="today">Today</string> <string name="today">Today</string>
<string name="yesterday">Yesterday</string> <string name="yesterday">Yesterday</string>
@ -377,6 +381,10 @@
<string name="invite_dialog_copy">Copy</string> <string name="invite_dialog_copy">Copy</string>
<string name="invite_dialog_close">Close</string> <string name="invite_dialog_close">Close</string>
<string name="pinned_messages">Pinned Messages</string>
<string name="pinned_messages_view">View Pinned Messages</string>
<string name="pinned_messages_empty">No pinned messages yet, maybe you should pin one?</string>
<string name="message_context_sheet_actions_copy">Copy</string> <string name="message_context_sheet_actions_copy">Copy</string>
<string name="message_context_sheet_actions_copy_failed_empty">Message is empty, nothing to copy</string> <string name="message_context_sheet_actions_copy_failed_empty">Message is empty, nothing to copy</string>
<string name="message_context_sheet_actions_reply">Reply</string> <string name="message_context_sheet_actions_reply">Reply</string>