From 46987baec9ea14022a9e7eaa2c5a1463d6971a2f Mon Sep 17 00:00:00 2001 From: infi Date: Fri, 31 Jul 2026 00:39:03 +0200 Subject: [PATCH] feat: pronouns --- .../chat/stoat/api/realtime/RealtimeSocket.kt | 15 ++-- .../frames/receivable/ReceivableFrames.kt | 2 +- .../java/chat/stoat/api/routes/user/User.kt | 13 ++- .../chat/stoat/composables/chat/Message.kt | 21 ++++- .../screens/settings/UserOverview.kt | 25 +++++- .../screens/settings/ProfileSettingsScreen.kt | 87 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + .../chat/stoat/core/model/schemas/Channel.kt | 2 + .../chat/stoat/core/model/schemas/User.kt | 3 + 9 files changed, 156 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt index 41de7ade..985c3b7b 100644 --- a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt @@ -478,15 +478,17 @@ object RealtimeSocket { val existing = StoatAPI.userCache[userUpdateFrame.id] ?: return // if we don't have the user no point in updating it - if (userUpdateFrame.clear != null) { - if (userUpdateFrame.clear.contains("Avatar")) { - StoatAPI.userCache[userUpdateFrame.id] = - existing.copy(avatar = null) + var updated = existing.mergeWithPartial(userUpdateFrame.data) + + userUpdateFrame.clear?.forEach { + updated = when (it) { + "Avatar" -> updated.copy(avatar = null) + "Pronouns" -> updated.copy(pronouns = null) + else -> updated } } - StoatAPI.userCache[userUpdateFrame.id] = - existing.mergeWithPartial(userUpdateFrame.data) + StoatAPI.userCache[userUpdateFrame.id] = updated } "UserRelationship" -> { @@ -756,6 +758,7 @@ object RealtimeSocket { when (it) { "Avatar" -> updated = updated.copy(avatar = null) "Nickname" -> updated = updated.copy(nickname = null) + "Pronouns" -> updated = updated.copy(pronouns = null) else -> Log.e("RealtimeSocket", "Unknown server member clear field: $it") } } diff --git a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt index c7a25423..ff5c9ac2 100644 --- a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt @@ -233,7 +233,7 @@ data class UserUpdateFrame( val type: String = "UserUpdate", val id: String, val data: User, - val clear: List? = null // "ProfileContent", "ProfileBackground", "StatusText" or "Avatar" + val clear: List? = null ) @Serializable diff --git a/app/src/main/java/chat/stoat/api/routes/user/User.kt b/app/src/main/java/chat/stoat/api/routes/user/User.kt index 3ee712d4..d63a43a9 100644 --- a/app/src/main/java/chat/stoat/api/routes/user/User.kt +++ b/app/src/main/java/chat/stoat/api/routes/user/User.kt @@ -45,6 +45,7 @@ suspend fun fetchSelf(): User { suspend fun patchSelf( status: Status? = null, + pronouns: String? = null, avatar: String? = null, background: String? = null, bio: String? = null, @@ -57,6 +58,10 @@ suspend fun patchSelf( body["status"] = StoatJson.encodeToJsonElement(Status.serializer(), status) } + if (pronouns != null) { + body["pronouns"] = StoatJson.encodeToJsonElement(String.serializer(), pronouns) + } + if (avatar != null) { body["avatar"] = StoatJson.encodeToJsonElement(String.serializer(), avatar) } @@ -104,7 +109,11 @@ suspend fun patchSelf( val currentUser = StoatAPI.userCache[StoatAPI.selfId] ?: fetchSelf() val newUserKeys = StoatJson.decodeFromString(User.serializer(), response) - val mergedUser = currentUser.mergeWithPartial(newUserKeys) + var mergedUser = currentUser.mergeWithPartial(newUserKeys) + + if ("Pronouns" in remove.orEmpty()) { + mergedUser = mergedUser.copy(pronouns = null) + } if (!pure) { StoatAPI.userCache[StoatAPI.selfId!!] = mergedUser @@ -159,4 +168,4 @@ suspend fun fetchUserProfile(id: String): Profile { } return StoatJson.decodeFromString(Profile.serializer(), response) -} \ No newline at end of file +} diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index e204b6b6..20a18711 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -156,6 +156,22 @@ fun authorName(message: MessageSchema): String { ?: stringResource(R.string.unknown) } +fun authorPronouns(message: MessageSchema, author: User): String? { + val serverId = StoatAPI.channelCache[message.channel]?.server + ?: return author.pronouns + + val memberPronouns = message.author + ?.let { StoatAPI.members.getMember(serverId, it) } + ?.pronouns + + return memberPronouns ?: author.pronouns +} + +internal fun messageTimestampText(pronouns: String?, timestamp: String): String { + val visiblePronouns = pronouns?.trim()?.takeIf { it.isNotEmpty() } + return visiblePronouns?.let { "$it ยท $timestamp" } ?: timestamp +} + @Composable fun authorAvatarUrl(message: MessageSchema): String? { if (message.masquerade?.avatar != null) { @@ -449,7 +465,10 @@ fun Message( Spacer(modifier = Modifier.width(5.dp)) Text( - text = formatLongAsTime(ULID.asTimestamp(message.id!!)), + text = messageTimestampText( + pronouns = authorPronouns(message, author), + timestamp = formatLongAsTime(ULID.asTimestamp(message.id!!)) + ), fontSize = 12.sp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), maxLines = 1, diff --git a/app/src/main/java/chat/stoat/composables/screens/settings/UserOverview.kt b/app/src/main/java/chat/stoat/composables/screens/settings/UserOverview.kt index 8d92c45d..ad9fd0a9 100644 --- a/app/src/main/java/chat/stoat/composables/screens/settings/UserOverview.kt +++ b/app/src/main/java/chat/stoat/composables/screens/settings/UserOverview.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,6 +30,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.stoat.R import chat.stoat.api.StoatAPI @@ -113,6 +115,9 @@ fun RawUserOverview( ) ) { val background = backgroundUrl ?: profile?.background + val contentColour = if (background != null) Color.White else LocalContentColor.current + val pronouns = user.pronouns?.trim()?.takeIf { it.isNotEmpty() } + if (background != null) { RemoteImage( url = backgroundUrl @@ -179,8 +184,26 @@ fun RawUserOverview( append("#${user.discriminator}") pop() }.toAnnotatedString(), - color = if (profile?.background != null) Color.White else LocalContentColor.current + color = contentColour, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) + + pronouns?.let { + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = it, + color = contentColour, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Bottom) + .widthIn(max = 140.dp) + ) + } } } } diff --git a/app/src/main/java/chat/stoat/screens/settings/ProfileSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/ProfileSettingsScreen.kt index 5718bbc4..07e1a9c6 100644 --- a/app/src/main/java/chat/stoat/screens/settings/ProfileSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/ProfileSettingsScreen.kt @@ -69,11 +69,18 @@ class ProfileSettingsScreenViewModel(val context: Application) : var uploadProgress by mutableFloatStateOf(0f) var uploadError by mutableStateOf(null) var bioError by mutableStateOf(null) + var currentPronouns by mutableStateOf(null) + var pendingPronouns by mutableStateOf("") + var pronounsError by mutableStateOf(null) init { StoatAPI.selfId?.let { self -> - StoatAPI.userCache[self]?.avatar?.id?.let { - pfpModel = "$STOAT_FILES/avatars/${it}" + StoatAPI.userCache[self]?.let { user -> + user.avatar?.id?.let { + pfpModel = "$STOAT_FILES/avatars/${it}" + } + currentPronouns = user.pronouns + pendingPronouns = user.pronouns.orEmpty() } viewModelScope.launch { currentProfile = fetchUserProfile(self) @@ -226,6 +233,26 @@ class ProfileSettingsScreenViewModel(val context: Application) : } } } + + fun savePronouns() { + pronounsError = null + val normalizedPronouns = pendingPronouns.trim() + + viewModelScope.launch { + try { + if (normalizedPronouns.isEmpty()) { + patchSelf(remove = listOf("Pronouns")) + } else { + patchSelf(pronouns = normalizedPronouns) + } + + currentPronouns = normalizedPronouns.ifEmpty { null } + pendingPronouns = normalizedPronouns + } catch (e: Exception) { + pronounsError = e.message + } + } + } } @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @@ -380,6 +407,62 @@ fun ProfileSettingsScreen( modifier = Modifier .padding(start = 20.dp, end = 20.dp, top = 0.dp, bottom = 20.dp) ) { + OutlinedTextField( + value = viewModel.pendingPronouns, + onValueChange = { value -> + if (value.length <= 24) { + viewModel.pendingPronouns = value + } + }, + label = { + Text( + text = stringResource(id = R.string.settings_profile_pronouns), + style = MaterialTheme.typography.labelLarge, + ) + }, + isError = viewModel.pronounsError != null, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + AnimatedVisibility(visible = viewModel.pronounsError != null) { + Spacer(Modifier.height(8.dp)) + + Text( + text = viewModel.pronounsError ?: "", + style = MaterialTheme.typography.labelLarge.copy( + color = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp) + ) + } + + Spacer(Modifier.height(8.dp)) + + TextButton( + onClick = { + viewModel.savePronouns() + }, + enabled = viewModel.pendingPronouns.trim().ifEmpty { null } != + viewModel.currentPronouns, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(R.drawable.ic_check_24dp), + contentDescription = null + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(id = R.string.settings_profile_save), + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(Modifier.height(16.dp)) + OutlinedTextField( value = viewModel.pendingProfile?.content ?: "", onValueChange = { value -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16efe400..3d24969b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -810,6 +810,7 @@ Profile Profile picture Custom background + Pronouns Save Sessions diff --git a/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt b/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt index 70b0a687..30bd08b9 100644 --- a/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt +++ b/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt @@ -35,6 +35,7 @@ data class Member( val avatar: AutumnResource? = null, val roles: List? = null, val nickname: String? = null, + val pronouns: String? = null, val timeout: String? = null ) { @@ -45,6 +46,7 @@ data class Member( avatar = other.avatar ?: avatar, roles = other.roles ?: roles, nickname = other.nickname ?: nickname, + pronouns = other.pronouns ?: pronouns, timeout = other.timeout ?: timeout ) } diff --git a/core/model/src/main/java/chat/stoat/core/model/schemas/User.kt b/core/model/src/main/java/chat/stoat/core/model/schemas/User.kt index de1b2592..54bdbc8a 100644 --- a/core/model/src/main/java/chat/stoat/core/model/schemas/User.kt +++ b/core/model/src/main/java/chat/stoat/core/model/schemas/User.kt @@ -14,6 +14,7 @@ data class User( val discriminator: String? = null, @SerialName("display_name") val displayName: String? = null, + val pronouns: String? = null, val avatar: AutumnResource? = null, val relations: List? = null, val badges: Long? = null, @@ -31,6 +32,7 @@ data class User( username = partial.username ?: username, discriminator = partial.discriminator ?: discriminator, displayName = partial.displayName ?: displayName, + pronouns = partial.pronouns ?: pronouns, avatar = partial.avatar ?: avatar, relations = partial.relations ?: relations, badges = partial.badges ?: badges, @@ -50,6 +52,7 @@ data class User( username = "Unknown User", discriminator = "0000", displayName = null, + pronouns = null, avatar = null, badges = 0, status = null,