From 058dcbcd507844bd933b0743dcb4666b64b118ff Mon Sep 17 00:00:00 2001 From: infi Date: Sat, 7 Mar 2026 23:53:34 +0100 Subject: [PATCH] feat: pinned messages view and system message --- .../chat/stoat/activities/MainActivity.kt | 8 +- .../chat/stoat/api/routes/channel/Channel.kt | 80 +++++++ .../stoat/composables/chat/SystemMessage.kt | 40 ++++ .../stoat/screens/chat/ChannelPinsScreen.kt | 196 ++++++++++++++++++ .../chat/views/channel/ChannelScreen.kt | 14 +- .../main/res/drawable/ic_keep_off_24dp.xml | 10 + .../main/res/drawable/ic_pinboard_24dp.xml | 10 + app/src/main/res/values/strings.xml | 8 + 8 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/chat/stoat/screens/chat/ChannelPinsScreen.kt create mode 100644 app/src/main/res/drawable/ic_keep_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_pinboard_24dp.xml diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index c9b20a58..0c91a030 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -82,19 +82,20 @@ import chat.stoat.api.api import chat.stoat.api.routes.microservices.geo.queryGeo import chat.stoat.api.routes.microservices.health.healthCheck 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.GeoStateProvider import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.SyncedSettings import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.voice.VoicePermissionSwitch +import chat.stoat.core.model.schemas.HealthNotice import chat.stoat.material.EasingTokens import chat.stoat.ndk.NativeLibraries import chat.stoat.persistence.KVStorage import chat.stoat.screens.DefaultDestinationScreen import chat.stoat.screens.about.AboutScreen import chat.stoat.screens.about.AttributionScreen +import chat.stoat.screens.chat.ChannelPinsScreen import chat.stoat.screens.chat.ChatRouterScreen import chat.stoat.screens.chat.standalone.CatchUpScreen import chat.stoat.screens.chat.views.channel.ChannelScreen @@ -733,6 +734,11 @@ fun AppEntrypoint( ChannelSettingsPermissions(navController, channelId) } + composable("channel/{channelId}/pins") { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + ChannelPinsScreen(navController, channelId) + } + composable("about") { AboutScreen(navController) } composable("about/oss") { AttributionScreen(navController) } diff --git a/app/src/main/java/chat/stoat/api/routes/channel/Channel.kt b/app/src/main/java/chat/stoat/api/routes/channel/Channel.kt index ade3489c..d7db82b1 100644 --- a/app/src/main/java/chat/stoat/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/stoat/api/routes/channel/Channel.kt @@ -255,4 +255,84 @@ suspend fun patchChannel( val channel = StoatJson.decodeFromString(Channel.serializer(), response) 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() + + 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() + ) + } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt index 3df30126..73721650 100644 --- a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt +++ b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt @@ -42,6 +42,8 @@ enum class SystemMessageType(val type: String) { USER_KICKED("user_kicked"), USER_LEFT("user_left"), USER_JOINED("user_joined"), + MESSAGE_PINNED("message_pinned"), + MESSAGE_UNPINNED("message_unpinned"), 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 -> { 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 -> { Icon( painter = painterResource(R.drawable.ic_info_24dp), @@ -302,6 +340,8 @@ private fun shapeForType(type: SystemMessageType): Shape { SystemMessageType.USER_KICKED -> MaterialShapes.SoftBurst SystemMessageType.USER_LEFT -> MaterialShapes.Cookie4Sided SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided + SystemMessageType.MESSAGE_PINNED -> MaterialShapes.Clover4Leaf + SystemMessageType.MESSAGE_UNPINNED -> MaterialShapes.Clover8Leaf SystemMessageType.TEXT -> MaterialShapes.Square }.toShape() } diff --git a/app/src/main/java/chat/stoat/screens/chat/ChannelPinsScreen.kt b/app/src/main/java/chat/stoat/screens/chat/ChannelPinsScreen.kt new file mode 100644 index 00000000..4053b9d9 --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/chat/ChannelPinsScreen.kt @@ -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() + var isLoading by mutableStateOf(false) + var error by mutableStateOf(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) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index f413c0ab..b2c9975a 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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)) } - } else if (ageGateUnlocked == true) { + } else if (ageGateUnlocked) { Column( modifier = Modifier .padding(pv) diff --git a/app/src/main/res/drawable/ic_keep_off_24dp.xml b/app/src/main/res/drawable/ic_keep_off_24dp.xml new file mode 100644 index 00000000..4e2e1589 --- /dev/null +++ b/app/src/main/res/drawable/ic_keep_off_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pinboard_24dp.xml b/app/src/main/res/drawable/ic_pinboard_24dp.xml new file mode 100644 index 00000000..052a456a --- /dev/null +++ b/app/src/main/res/drawable/ic_pinboard_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d3973c9..9c4dfeff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,6 +277,8 @@ User kicked User left User joined + Message pinned + Message unpinned System message %1$s transferred ownership to %2$s @@ -289,6 +291,8 @@ %1$s has been kicked %1$s left %1$s joined + %1$s pinned a message to this channel + %1$s unpinned a message from this channel Today Yesterday @@ -377,6 +381,10 @@ Copy Close + Pinned Messages + View Pinned Messages + No pinned messages yet, maybe you should pin one? + Copy Message is empty, nothing to copy Reply