Skip to content

Commit ef8859e

Browse files
committed
feat(chat): show a bell wherever a muted chat is named
Muting had no trace outside the profile that set it. A muted chat looked like any other in the list and in its own transcript, so the only way to tell why it had gone quiet was to open the profile and read the row. MutedIndicator draws a bell at caption size in the secondary text colour, on the chats list row and in the transcript's title bar. It takes the viewer state rather than a muted boolean: a timed mute lapses with nothing sent to say so, so a caller that resolved the answer when it built the row would hand over a value that goes stale in place. rememberIsMuted moves to chat-ui from messenger so both surfaces share the one implementation that waits the deadline out. An audible chat emits no layout node at all, so neither Row spends its spacing on it and the list row keeps its full preview width. The bell carries its own contentDescription and needs no help from its container: a Compose merging node concatenates a child's description into its own rather than dropping it, so the row reads its name, its timestamp and then "Muted". MutedIndicatorTest asserts that through a clickable Row rather than taking it on trust.
1 parent bcc454b commit ef8859e

7 files changed

Lines changed: 247 additions & 38 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ package com.flipcash.app.messenger.internal
22

33
import android.text.format.DateFormat
44
import androidx.compose.runtime.Composable
5-
import androidx.compose.runtime.produceState
65
import androidx.compose.ui.platform.LocalContext
76
import androidx.compose.ui.res.stringResource
87
import com.flipcash.features.messenger.R
98
import com.flipcash.services.models.chat.MuteState
109
import com.flipcash.services.models.chat.ViewerState
11-
import com.flipcash.services.models.chat.isMutedAt
10+
import com.flipcash.shared.chat.ui.rememberIsMuted
1211
import com.getcode.util.formatLocalized
13-
import kotlinx.coroutines.delay
1412
import kotlinx.datetime.LocalDate
1513
import kotlinx.datetime.TimeZone
1614
import kotlinx.datetime.toLocalDateTime
@@ -47,26 +45,6 @@ internal enum class MuteOption(
4745
duration?.let { MuteState.Until(now + it) } ?: MuteState.Forever
4846
}
4947

50-
/**
51-
* Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses.
52-
*
53-
* A composable rather than a plain read because the lapse has no event behind it. Nothing arrives
54-
* from the server when a deadline passes, and nothing writes the row, so a screen that read the
55-
* state once would keep offering Unmute on a chat that is already audible. This waits out the
56-
* remaining time and then answers false.
57-
*/
58-
@Composable
59-
internal fun rememberIsMuted(viewerState: ViewerState?): Boolean {
60-
val mute = viewerState?.mute
61-
return produceState(initialValue = viewerState.isMutedAt(), mute) {
62-
value = viewerState.isMutedAt()
63-
val until = (mute as? MuteState.Until)?.until ?: return@produceState
64-
val remaining = until - Clock.System.now()
65-
if (remaining.isPositive()) delay(remaining)
66-
value = false
67-
}.value
68-
}
69-
7048
/**
7149
* How much of a mute deadline has to be said for it to locate the moment.
7250
*

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.flipcash.shared.chat.MessageCapability
5050
import com.flipcash.shared.chat.models.ChatAction
5151
import com.flipcash.shared.chat.models.ChatActionHandler
5252
import com.flipcash.shared.chat.models.ChatListItem
53+
import com.flipcash.shared.chat.ui.MutedIndicator
5354
import com.getcode.navigation.core.CodeNavigator
5455
import com.getcode.theme.CodeTheme
5556
import com.getcode.theme.extraLarge
@@ -167,14 +168,29 @@ private fun ConversationTitleBar(
167168
)
168169

169170
Column(modifier = Modifier.weight(1f)) {
170-
Text(
171-
// Name-or-handle for a DM, the group's title for a group. A DM bar stays
172-
// one line (node 9443:9094): the handle is the only identity a name-less
173-
// tip counterparty has, so it is the title there rather than a second row.
174-
text = state.subject?.title.orEmpty(),
175-
style = CodeTheme.typography.textMedium,
176-
color = CodeTheme.colors.textMain,
177-
)
171+
Row(
172+
verticalAlignment = Alignment.CenterVertically,
173+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
174+
) {
175+
Text(
176+
// Name-or-handle for a DM, the group's title for a group. A DM bar
177+
// stays one line (node 9443:9094): the handle is the only identity a
178+
// name-less tip counterparty has, so it is the title there rather than
179+
// a second row.
180+
//
181+
// Gives up width to the indicator rather than taking the whole line,
182+
// so a long name loses its tail instead of pushing the bell off the bar.
183+
modifier = Modifier.weight(1f, fill = false),
184+
text = state.subject?.title.orEmpty(),
185+
style = CodeTheme.typography.textMedium,
186+
color = CodeTheme.colors.textMain,
187+
)
188+
189+
// Beside the name, not at the bar's edge: what is muted is this chat, and
190+
// the name is what says which chat. An audible one emits nothing, so the
191+
// spacing above is not spent either.
192+
MutedIndicator(viewerState = state.viewerState)
193+
}
178194
// A group always shows its size, whether or not the viewer is in it — the
179195
// count is what a join changes, and node 10125:19153 -> 10125:19201 is the
180196
// same bar at two values of it. A DM has nothing to count.

‎apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package com.flipcash.app.tipping.internal.components
22

3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Row
36
import androidx.compose.foundation.layout.requiredSize
47
import androidx.compose.foundation.shape.CircleShape
58
import androidx.compose.material3.Text
69
import androidx.compose.runtime.Composable
10+
import androidx.compose.ui.Alignment
711
import androidx.compose.ui.Modifier
812
import androidx.compose.ui.draw.clip
913
import com.flipcash.shared.chat.ui.ChatListRow
1014
import com.flipcash.shared.chat.ui.ChatRowSubtitle
1115
import com.flipcash.shared.chat.ui.ChatRowTrailing
1216
import com.flipcash.shared.chat.ui.ConversationReference
17+
import com.flipcash.shared.chat.ui.MutedIndicator
1318
import com.flipcash.shared.chat.ui.SubtitleText
1419
import com.flipcash.shared.common.ui.ContactAvatar
1520
import com.getcode.theme.CodeTheme
@@ -51,13 +56,27 @@ internal fun TipChatRow(
5156
)
5257
},
5358
subtitle = {
54-
ChatRowSubtitle(
55-
isTyping = chat.isTyping,
56-
preview = chat.lastMessagePreview,
57-
fallback = {
58-
SubtitleText("")
59+
// The mute sits at the end of this line rather than the one above it, where the
60+
// timestamp and unread badge already compete for the trailing corner. It reads as the
61+
// bottom of a column of chat-level state, and matches where iOS draws it.
62+
Row(
63+
verticalAlignment = Alignment.CenterVertically,
64+
// An audible chat emits no indicator at all, so the gap is not spent either and
65+
// the preview keeps the full width.
66+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
67+
) {
68+
Box(modifier = Modifier.weight(1f)) {
69+
ChatRowSubtitle(
70+
isTyping = chat.isTyping,
71+
preview = chat.lastMessagePreview,
72+
fallback = {
73+
SubtitleText("")
74+
}
75+
)
5976
}
60-
)
77+
78+
MutedIndicator(viewerState = chat.viewerState)
79+
}
6180
},
6281
showDivider = showDivider,
6382
onClick = onClick,

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import com.getcode.util.resources.ResourceHelper
1313
/**
1414
* Maps a [ChatSummary] to the presentation [ConversationReference] shared by every
1515
* conversation-style list (send contacts, tip DMs, …): counterparty identity, a
16-
* formatted last-message preview, the last-activity timestamp, and unread count.
16+
* formatted last-message preview, the last-activity timestamp, unread count, and the viewer's
17+
* own state on the chat.
1718
*
1819
* The counterparty ([ConversationReference.displayName]/[ConversationReference.image])
1920
* is taken from the chat member that isn't [selfId] — used directly by rows with no
@@ -43,6 +44,7 @@ fun ChatSummary.toConversationReference(
4344
lastMessagePreview = formatPreview(selfId, tokensByMint, resources),
4445
lastActivity = metadata.lastActivity,
4546
unreadCount = unreadCount,
47+
viewerState = metadata.viewerState,
4648
)
4749
}
4850

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.getcode.opencode.model.core.ID
44
import com.flipcash.services.models.chat.BlobAccessContext
55
import com.flipcash.services.models.chat.ChatId
66
import com.flipcash.services.models.chat.MediaItem
7+
import com.flipcash.services.models.chat.ViewerState
78
import com.flipcash.services.models.nameOrHandle
89
import kotlin.time.Instant
910

@@ -34,6 +35,14 @@ data class ConversationReference(
3435
val lastActivity: Instant? = null,
3536
val unreadCount: Int = 0,
3637
val isTyping: Boolean = false,
38+
/**
39+
* The viewer's own state on this chat, which is where the row's mute comes from.
40+
*
41+
* The state itself rather than a muted flag: a timed mute lapses with nothing sent to say so,
42+
* so a boolean resolved when the row was built would go stale in place. [MutedIndicator] holds
43+
* the deadline and stops drawing at it.
44+
*/
45+
val viewerState: ViewerState? = null,
3746
) {
3847
/**
3948
* What to call this row: the chat's [title] for a group, otherwise the counterparty's
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.flipcash.shared.chat.ui
2+
3+
import androidx.compose.foundation.layout.size
4+
import androidx.compose.material.icons.Icons
5+
import androidx.compose.material.icons.outlined.NotificationsOff
6+
import androidx.compose.material3.Icon
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.produceState
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.res.stringResource
11+
import com.flipcash.core.R
12+
import com.flipcash.services.models.chat.MuteState
13+
import com.flipcash.services.models.chat.ViewerState
14+
import com.flipcash.services.models.chat.isMutedAt
15+
import com.getcode.theme.CodeTheme
16+
import kotlinx.coroutines.delay
17+
import kotlin.time.Clock
18+
19+
/**
20+
* The bell drawn wherever a chat is named, while that chat is muted right now.
21+
*
22+
* Takes the viewer state rather than a muted boolean on purpose. A timed mute lapses with nothing
23+
* sent to say so, so a caller that resolved the answer itself would hand this a value that is
24+
* already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops
25+
* drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it.
26+
*
27+
* Sized and tinted to sit with the caption text it stands beside — the row's timestamp, the title
28+
* bar's name — rather than to be noticed on its own. It states a setting, it does not ask for
29+
* anything.
30+
*/
31+
@Composable
32+
fun MutedIndicator(
33+
viewerState: ViewerState?,
34+
modifier: Modifier = Modifier,
35+
) {
36+
if (!rememberIsMuted(viewerState)) return
37+
Icon(
38+
modifier = modifier.size(CodeTheme.dimens.staticGrid.x4),
39+
imageVector = Icons.Outlined.NotificationsOff,
40+
// Reaches a screen reader through the merged label of whatever container holds it —
41+
// Compose concatenates a child's description into its parent's rather than dropping it,
42+
// so the row reads its name, its timestamp and then this.
43+
contentDescription = stringResource(R.string.label_muted),
44+
tint = CodeTheme.colors.textSecondary,
45+
)
46+
}
47+
48+
/**
49+
* Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses.
50+
*
51+
* A composable rather than a plain read because the lapse has no event behind it. Nothing arrives
52+
* from the server when a deadline passes, and nothing writes the row, so a screen that read the
53+
* state once would keep showing a chat as muted after it had gone audible. This waits out the
54+
* remaining time and then answers false.
55+
*/
56+
@Composable
57+
fun rememberIsMuted(viewerState: ViewerState?): Boolean {
58+
val mute = viewerState?.mute
59+
return produceState(initialValue = viewerState.isMutedAt(), mute) {
60+
value = viewerState.isMutedAt()
61+
val until = (mute as? MuteState.Until)?.until ?: return@produceState
62+
val remaining = until - Clock.System.now()
63+
if (remaining.isPositive()) delay(remaining)
64+
value = false
65+
}.value
66+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.flipcash.shared.chat.ui
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Row
5+
import androidx.compose.material3.Text
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.test.assertIsDisplayed
8+
import androidx.compose.ui.test.hasClickAction
9+
import androidx.compose.ui.test.hasContentDescription
10+
import androidx.compose.ui.test.junit4.createComposeRule
11+
import androidx.compose.ui.test.onNodeWithContentDescription
12+
import com.flipcash.services.models.chat.MuteState
13+
import com.flipcash.services.models.chat.ViewerState
14+
import com.getcode.theme.DesignSystem
15+
import org.junit.Rule
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.RobolectricTestRunner
19+
import kotlin.time.Clock
20+
import kotlin.time.Duration.Companion.hours
21+
22+
/**
23+
* The indicator's two questions: whether it draws, and whether a screen reader hears it.
24+
*
25+
* The second is the reason this is a Compose test rather than a unit test of [rememberIsMuted].
26+
* Both surfaces that draw the bell sit inside a merging semantics node — a clickable row, a
27+
* tappable title — and what such a node does with a child's description is the platform's choice,
28+
* not ours. Compose concatenates rather than discarding, which is why the bell carries its own
29+
* description instead of the containers appending to theirs; if that ever stopped being true the
30+
* indicator would go silent with nothing else failing.
31+
*/
32+
@RunWith(RobolectricTestRunner::class)
33+
class MutedIndicatorTest {
34+
35+
@get:Rule
36+
val composeTestRule = createComposeRule()
37+
38+
private val mutedLabel = "Muted"
39+
40+
private fun setIndicator(viewerState: ViewerState?) {
41+
composeTestRule.setContent {
42+
DesignSystem {
43+
MutedIndicator(viewerState = viewerState)
44+
}
45+
}
46+
}
47+
48+
// ---------------------------------------------------------------
49+
// Whether it draws
50+
// ---------------------------------------------------------------
51+
52+
@Test
53+
fun `drawn while muted forever`() {
54+
setIndicator(ViewerState(mute = MuteState.Forever))
55+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed()
56+
}
57+
58+
@Test
59+
fun `drawn while a timed mute is still running`() {
60+
setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() + 1.hours)))
61+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed()
62+
}
63+
64+
@Test
65+
fun `not drawn once a timed mute has lapsed`() {
66+
// The state the server last sent, read after its deadline. Nothing arrives to clear it, so
67+
// this is what a chat that has gone audible on its own actually looks like in the store.
68+
setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() - 1.hours)))
69+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist()
70+
}
71+
72+
@Test
73+
fun `not drawn when the chat is not muted`() {
74+
setIndicator(ViewerState(mute = null))
75+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist()
76+
}
77+
78+
@Test
79+
fun `not drawn when there is no viewer state`() {
80+
setIndicator(null)
81+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist()
82+
}
83+
84+
// ---------------------------------------------------------------
85+
// Whether a screen reader hears it
86+
// ---------------------------------------------------------------
87+
88+
@Test
89+
fun `description survives a merging container`() {
90+
composeTestRule.setContent {
91+
DesignSystem {
92+
Row(modifier = Modifier.clickable {}) {
93+
Text("Alice")
94+
MutedIndicator(viewerState = ViewerState(mute = MuteState.Forever))
95+
}
96+
}
97+
}
98+
99+
// The clickable is the merged node; finding the description on it is what says the bell
100+
// reached the label the row is read out as, rather than only existing under it.
101+
composeTestRule
102+
.onNode(hasClickAction() and hasContentDescription(mutedLabel))
103+
.assertExists()
104+
}
105+
106+
@Test
107+
fun `merging container says nothing about muting when the chat is audible`() {
108+
composeTestRule.setContent {
109+
DesignSystem {
110+
Row(modifier = Modifier.clickable {}) {
111+
Text("Alice")
112+
MutedIndicator(viewerState = null)
113+
}
114+
}
115+
}
116+
117+
composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist()
118+
}
119+
}

0 commit comments

Comments
 (0)