feat: pronouns

This commit is contained in:
infi 2026-07-31 00:39:03 +02:00
parent 3ac331ac1f
commit 46987baec9
9 changed files with 156 additions and 13 deletions

View File

@ -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")
}
}

View File

@ -233,7 +233,7 @@ data class UserUpdateFrame(
val type: String = "UserUpdate",
val id: String,
val data: User,
val clear: List<String>? = null // "ProfileContent", "ProfileBackground", "StatusText" or "Avatar"
val clear: List<String>? = null
)
@Serializable

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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)
)
}
}
}
}

View File

@ -69,11 +69,18 @@ class ProfileSettingsScreenViewModel(val context: Application) :
var uploadProgress by mutableFloatStateOf(0f)
var uploadError by mutableStateOf<String?>(null)
var bioError by mutableStateOf<String?>(null)
var currentPronouns by mutableStateOf<String?>(null)
var pendingPronouns by mutableStateOf("")
var pronounsError by mutableStateOf<String?>(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 ->

View File

@ -810,6 +810,7 @@
<string name="settings_profile">Profile</string>
<string name="settings_profile_profile_picture">Profile picture</string>
<string name="settings_profile_custom_background">Custom background</string>
<string name="settings_profile_pronouns">Pronouns</string>
<string name="settings_profile_save">Save</string>
<string name="settings_sessions">Sessions</string>

View File

@ -35,6 +35,7 @@ data class Member(
val avatar: AutumnResource? = null,
val roles: List<String>? = 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
)
}

View File

@ -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<Relation>? = 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,