Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) } },
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ fun diffBioLines(oldBody: String, newBody: String): List<BioDiffLine> {
data class MemberFormState(
val name: String = "",
val displayName: String = "",
val emoji: String = "",
val pronouns: String = "",
val description: String = "",
val note: String = "",
Expand Down Expand Up @@ -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 ?: "",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading