feat: pinned messages view and system message
This commit is contained in:
parent
2427f9e6d4
commit
058dcbcd50
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -256,3 +256,83 @@ suspend fun patchChannel(
|
|||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -277,6 +277,8 @@
|
|||
<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_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_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_left">%1$s left</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="yesterday">Yesterday</string>
|
||||
|
|
@ -377,6 +381,10 @@
|
|||
<string name="invite_dialog_copy">Copy</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_failed_empty">Message is empty, nothing to copy</string>
|
||||
<string name="message_context_sheet_actions_reply">Reply</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue