From f749e7c088de05da69df2d9d81d85ecea5988802 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:26:40 -0400 Subject: [PATCH] feat(members): set a member's emoji, and show it beside their name The phone could read a member's emoji but never write one, and only showed it as a stand-in avatar. So it was invisible to anyone who had also set an avatar picture, and unreachable entirely unless they had gone to web to set it. Adds an Emoji field to the member editor, next to Display Name, and threads it through MemberCreate and MemberUpdate, which did not carry the field at all even though the server has accepted it on both for a long time. Input is capped at the server's 8, counted in code points rather than chars: a single emoji is often several UTF-16 units and a family sequence is seven, so counting chars would reject ordinary input. Clearing sends an empty string rather than null. The member PATCH is omit-means-unchanged and Moshi drops nulls, so a null would silently leave the old emoji in place and the field could be set but never unset. The scratchpad note already does the same thing for the same reason. Display now matches web and the watch: the emoji sits in front of the name in the members list, the profile title, the fronting cards on Home, the switch sheet, the quick-switch chips, history rows, and the member rows inside a group. Sorting, searching and content descriptions keep the plain name, since a prefix there would file the roster under one character, break name search, and make a screen reader read an emoji before every name. --- .../systems/lupine/sheaf/data/model/Models.kt | 19 +++++++++ .../lupine/sheaf/ui/groups/GroupsScreen.kt | 4 +- .../lupine/sheaf/ui/history/HistoryScreen.kt | 4 +- .../lupine/sheaf/ui/home/HomeScreen.kt | 6 +-- .../lupine/sheaf/ui/members/MembersScreen.kt | 25 +++++++++++- .../sheaf/ui/members/MembersViewModel.kt | 10 +++++ .../lupine/sheaf/ui/people/PeopleScreen.kt | 2 +- .../sheaf/data/model/ModelContractsTest.kt | 39 +++++++++++++++++++ 8 files changed, 100 insertions(+), 9 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index c557904..bd6e7dc 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -441,6 +441,19 @@ data class MemberRead( @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) { val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name + + /** + * The name as it should be shown, with the member's emoji in front when + * they have one. Matches web and the watch, where a member's emoji sits + * beside their name. + * + * Display only. Sorting, searching and content descriptions must keep using + * [displayNameOrName]: an emoji prefix would file the whole roster under + * one character and stop a name query matching. + */ + val displayNameWithEmoji: String get() = + emoji?.takeIf { it.isNotBlank() }?.let { "$it $displayNameOrName" } ?: displayNameOrName + val isArchived: Boolean get() = archivedAt != null val initials: String get() = displayNameOrName .split("\\s+".toRegex()) @@ -462,6 +475,9 @@ data class MemberCreate( val birthday: String? = null, val privacy: String = "private", val note: String? = null, + // Short glyph shown beside the member's name and in place of an + // avatar. Server caps it at 8 code points. + val emoji: String? = null, ) /** Optional step-up credentials for archiving a member. Only consulted when @@ -485,6 +501,9 @@ data class MemberUpdate( val birthday: String? = null, val privacy: String? = null, val note: String? = null, + // Short glyph shown beside the member's name and in place of an + // avatar. Server caps it at 8 code points. + val emoji: String? = null, ) // ── Fronts ──────────────────────────────────────────────────────────────────── diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt index 12a96bc..f19bfb0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt @@ -152,7 +152,7 @@ private fun GroupMembersInline( MemberAvatar(m, size = 32.dp) Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { Text( - m.displayNameOrName, + m.displayNameWithEmoji, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -352,7 +352,7 @@ fun GroupDetailScreen( items(filtered, key = { it.id }) { member -> val isSelected = member.id in state.memberSelection ListItem( - headlineContent = { Text(member.displayNameOrName) }, + headlineContent = { Text(member.displayNameWithEmoji) }, leadingContent = { MemberAvatar(member, size = 40.dp) }, trailingContent = { Checkbox(checked = isSelected, onCheckedChange = { viewModel.toggleMember(member.id) }) }, modifier = Modifier.padding(horizontal = 4.dp), diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 1890b2e..2fd83cd 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -450,7 +450,7 @@ private fun TimelineSection( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Canvas(modifier = Modifier.size(8.dp)) { drawCircle(dotColor) } - Text(member.displayNameOrName, style = MaterialTheme.typography.labelSmall) + Text(member.displayNameWithEmoji, style = MaterialTheme.typography.labelSmall) } } } @@ -832,7 +832,7 @@ private fun FrontEntrySheet( Spacer(Modifier.width(8.dp)) MemberAvatar(member = member, size = 32.dp) Spacer(Modifier.width(8.dp)) - Text(member.displayNameOrName, style = MaterialTheme.typography.bodyMedium) + Text(member.displayNameWithEmoji, style = MaterialTheme.typography.bodyMedium) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt index eb1211b..059746a 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt @@ -416,7 +416,7 @@ private fun FrontingMemberCard(member: MemberRead, front: FrontRead?, onLongClic MemberAvatar(member = member, size = 56.dp) Column(modifier = Modifier.weight(1f)) { Text( - member.displayNameOrName, + member.displayNameWithEmoji, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -523,7 +523,7 @@ private fun SwitchFrontSheet( items(filtered, key = { it.id }) { member -> val isSelected = member.id in selected ListItem( - headlineContent = { Text(member.displayNameOrName) }, + headlineContent = { Text(member.displayNameWithEmoji) }, supportingContent = member.pronouns?.let { { Text(it) } }, leadingContent = { MemberAvatar(member, size = 40.dp) }, trailingContent = { @@ -832,7 +832,7 @@ private fun QuickSwitchChip( MemberAvatar(member = member, size = 32.dp) Spacer(Modifier.width(8.dp)) Text( - text = member.displayNameOrName, + text = member.displayNameWithEmoji, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 0845a83..eccbdcb 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -56,6 +56,9 @@ import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +// Matches the server's max_length on the member emoji field. +private const val MEMBER_EMOJI_MAX = 8 + // ── Members list ────────────────────────────────────────────────────────────── @Composable @@ -746,6 +749,26 @@ fun MemberDetailScreen( modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = form.emoji, + // The server caps this at 8 code points, so cap the input at the + // same thing rather than letting a long paste come back a 422. + // Counted in code points, not chars: a single emoji can be + // several UTF-16 units, and a family sequence is seven. + onValueChange = { input -> + val trimmed = if (input.codePointCount(0, input.length) > MEMBER_EMOJI_MAX) { + input.substring(0, input.offsetByCodePoints(0, MEMBER_EMOJI_MAX)) + } else { + input + } + viewModel.updateForm { copy(emoji = trimmed) } + }, + label = { Text("Emoji") }, + supportingText = { Text("Shown beside their name, and instead of an avatar when they have no picture") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( value = form.pronouns, onValueChange = { viewModel.updateForm { copy(pronouns = it) } }, @@ -922,7 +945,7 @@ fun MemberProfileScreen( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { SheafLargeFlexibleTopAppBar( - title = { Text(member?.displayNameOrName ?: "Profile") }, + title = { Text(member?.displayNameWithEmoji ?: "Profile") }, subtitle = member?.pronouns?.let { { Text(it) } }, navigationIcon = { IconButton(onClick = onNavigateUp) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index 57179a0..ca5e815 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -299,6 +299,7 @@ fun diffBioLines(oldBody: String, newBody: String): List { data class MemberFormState( val name: String = "", val displayName: String = "", + val emoji: String = "", val pronouns: String = "", val description: String = "", val note: String = "", @@ -402,6 +403,7 @@ class MemberDetailViewModel @Inject constructor( val loaded = MemberFormState( name = m.name, displayName = m.displayName ?: "", + emoji = m.emoji ?: "", pronouns = m.pronouns ?: "", description = m.description ?: "", note = m.note ?: "", @@ -459,6 +461,7 @@ class MemberDetailViewModel @Inject constructor( api.createMember(MemberCreate( name = f.name.trim(), displayName = f.displayName.takeIf { it.isNotBlank() }, + emoji = f.emoji.trim().takeIf { it.isNotEmpty() }, pronouns = f.pronouns.takeIf { it.isNotBlank() }, description = f.description.takeIf { it.isNotBlank() }, avatarUrl = f.avatarUrl, @@ -472,6 +475,13 @@ class MemberDetailViewModel @Inject constructor( val update = MemberUpdate( name = f.name.trim(), displayName = f.displayName.takeIf { it.isNotBlank() }, + // Empty string rather than null, so removing an emoji + // actually removes it. The member PATCH is + // omit-means-unchanged and Moshi drops nulls, so a null + // here would silently leave the old emoji in place. Same + // trick the scratchpad note uses. Reads back as "no + // emoji" everywhere, since every client blank-checks it. + emoji = f.emoji.trim(), pronouns = f.pronouns.takeIf { it.isNotBlank() }, description = f.description.takeIf { it.isNotBlank() }, avatarUrl = f.avatarUrl, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt index 741437b..d27727a 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt @@ -355,7 +355,7 @@ private fun MemberCard(member: MemberRead, onClick: () -> Unit) { MemberAvatar(member, size = 40.dp) Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { Text( - member.displayNameOrName, + member.displayNameWithEmoji, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt index 2408740..941713c 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt @@ -85,6 +85,45 @@ class ModelContractsTest { assertEquals("AB", member("alex bell").initials) } + @Test fun `the display name carries the emoji in front`() { + assertEquals("\uD83E\uDD8A Alex", member("Alex").copy(emoji = "\uD83E\uDD8A").displayNameWithEmoji) + } + + @Test fun `no emoji leaves the name untouched`() { + assertEquals("Alex", member("Alex").displayNameWithEmoji) + assertEquals("Alex", member("Alex").copy(emoji = "").displayNameWithEmoji) + assertEquals("Alex", member("Alex").copy(emoji = " ").displayNameWithEmoji) + } + + @Test fun `the emoji form respects the display name`() { + val m = member(name = "ashley", displayName = "Sam Rivers").copy(emoji = "\u2728") + assertEquals("\u2728 Sam Rivers", m.displayNameWithEmoji) + } + + @Test fun `the plain name stays free of the emoji`() { + // Sorting, searching and content descriptions use displayNameOrName. + // An emoji leaking in would file the roster under one character and + // stop a name query matching. + val m = member("Alex").copy(emoji = "\uD83E\uDD8A") + assertEquals("Alex", m.displayNameOrName) + assertEquals("A", m.initials) + } + + @Test fun `an emoji reaches the wire on create and update`() { + val create = moshi.adapter(MemberCreate::class.java) + .toJson(MemberCreate(name = "Alex", emoji = "\u2728")) + assertEquals(true, "\"emoji\"" in create) + } + + @Test fun `clearing an emoji sends an empty string, not an omission`() { + // The member PATCH is omit-means-unchanged, and Moshi drops nulls, so + // a null emoji would silently leave the old one in place. The empty + // string is what actually clears it. + val json = moshi.adapter(MemberUpdate::class.java) + .toJson(MemberUpdate(name = "Alex", emoji = "")) + assertEquals(true, "\"emoji\":\"\"" in json) + } + @Test fun `archived is derived from the timestamp`() { val active = member("Alex") assertEquals(false, active.isArchived)