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)