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
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ internal class ChatViewModel @Inject constructor(
val chatType: ChatType = ChatType.UNKNOWN,
val chatInputState: TextFieldState = TextFieldState(),
val typists: Set<ActiveTypist> = emptySet(),
/** What the typing indicator draws ahead of its dots. See [typingAvatars]. */
val typingAvatars: List<TypingAvatar> = emptyList(),
val resolveState: ResolveState = ResolveState.Pending,
val sendProgress: LoadingSuccessState = LoadingSuccessState(),
val isSelfTyping: Boolean = false,
Expand Down Expand Up @@ -467,6 +469,7 @@ internal class ChatViewModel @Inject constructor(
data object OnStopMessageInput: Event
data object OnMessageInputConsumed: Event
data class TypistsUpdated(val typists: Set<ActiveTypist>) : Event
data class TypingAvatarsUpdated(val avatars: List<TypingAvatar>) : Event
data object ResolveCompleted : Event
data object ResolveFailed : Event

Expand Down Expand Up @@ -1445,6 +1448,27 @@ internal class ChatViewModel @Inject constructor(
.onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) }
.launchIn(viewModelScope)

// Faces for the typing indicator, from the same profiles the transcript attributes
// messages with. A typist the roster doesn't cover is asked for, like an unknown sender,
// and draws the fallback until their profile lands.
combine(
stateFlow.map { it.typists }.distinctUntilChanged(),
stateFlow.map { it.chatType }.distinctUntilChanged(),
senderProfiles,
::Triple,
)
.onEach { (typists, _, profiles) ->
profiles ?: return@onEach
typists.filter { it.userId.hexEncodedString() !in profiles }
.forEach { chatCoordinator.requestSenderProfile(it.userId) }
}
.map { (typists, chatType, profiles) ->
typingAvatars(typists, chatType, profiles.orEmpty())
}
.distinctUntilChanged()
.onEach { dispatchEvent(Event.TypingAvatarsUpdated(it)) }
.launchIn(viewModelScope)

// A DM opens its composer once a payment has been exchanged. A group has no such
// exchange to wait for: its own rules say who may post, and [GroupAccess] has already
// applied them — a group that reaches the composer at all is [GroupAccess.Membered], and
Expand Down Expand Up @@ -2225,6 +2249,7 @@ internal class ChatViewModel @Inject constructor(
Event.OnStopMessageInput -> { state -> state }
Event.OnMessageInputConsumed -> { state -> state.copy(messageInputRequested = false) }
is Event.TypistsUpdated -> { state -> state.copy(typists = event.typists) }
is Event.TypingAvatarsUpdated -> { state -> state.copy(typingAvatars = event.avatars) }
Event.ResolveCompleted -> { state ->
state.copy(resolveState = ResolveState.Resolved)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.flipcash.app.messenger.internal

import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.ActiveTypist
import com.getcode.opencode.model.core.ID
import com.getcode.utils.hexEncodedString

/**
* One face in the typing indicator. [profile] is null until the member's profile resolves, and the
* avatar draws what the transcript draws for a sender with no picture and no name until then.
*/
internal data class TypingAvatar(
val userId: ID,
val profile: UserProfile?,
) {
/** Stable per user, and a String, so it can serve as the indicator's lazy item key. */
val key: String get() = userId.hexEncodedString()
}

/**
* What the typing indicator draws ahead of its dots: one entry per typist, oldest first, so the
* indicator's `takeLast` keeps the people who started most recently.
*
* Group chats only. A DM or tip chat already names its one counterparty in the title bar, so it
* keeps the dots on their own and this is empty.
*/
internal fun typingAvatars(
typists: Set<ActiveTypist>,
chatType: ChatType,
profiles: Map<String, UserProfile>,
): List<TypingAvatar> {
if (chatType != ChatType.GROUP) return emptyList()

return typists
.sortedWith(compareBy({ it.since }, { it.userId.hexEncodedString() }))
.map { typist -> TypingAvatar(typist.userId, profiles[typist.userId.hexEncodedString()]) }
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
Expand Down Expand Up @@ -51,11 +52,13 @@ import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.app.messenger.internal.screens.ChatAnimations
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.features.messenger.R
import com.getcode.theme.CodeTheme
import com.flipcash.shared.chat.ui.ComposerReplyStrip
import com.getcode.ui.components.chat.ChatInput
import com.getcode.ui.components.chat.ChatInputSubmit
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.ui.components.chat.TypingIndicator
import com.getcode.ui.core.drawWithGradient
import com.getcode.ui.core.measured
Expand Down Expand Up @@ -125,14 +128,30 @@ internal fun UserControlBottomBar(
ChatAnimations.typingIndicator,
targetScale = 0.95f,
transformOrigin = TransformOrigin(0f, 0.5f)
) + fadeOut(ChatAnimations.typingIndicator))
) + fadeOut(ChatAnimations.typingIndicator)) using
// The container shrinks to nothing as the indicator leaves; unclipped, the
// indicator fades out whole instead of being cut off by the shrinking bounds.
SizeTransform(clip = false)
}
) { show ->
if (show) {
TypingIndicator(
typists = state.typingAvatars,
key = { it.key },
modifier = Modifier
.hazeBlur(HazeInput.Sources(hazeState), material),
)
) { typist ->
// Drawn the way the transcript's sender gutter draws the same member, so the
// face beside the dots is the face beside their messages. A typist whose
// profile hasn't resolved goes through the same call with no picture and no
// name, rather than a separate unknown-person glyph.
val profile = typist.profile
ContactAvatar(
image = profile?.profilePicture,
displayName = profile?.displayName.orEmpty(),
access = BlobAccessContext.profile(typist.userId),
)
}
}
}
Box {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.flipcash.app.messenger.internal

import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.ActiveTypist
import com.getcode.opencode.model.core.ID
import com.getcode.utils.hexEncodedString
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant

/**
* The faces the typing indicator draws. Pinned: a group lists its typists oldest first so the
* indicator's `takeLast` keeps the newest, a DM or tip chat lists nobody, and a typist who stops
* drops out.
*/
class TypingAvatarsTest {

private val ada: ID = List(16) { 1 }
private val grace: ID = List(16) { 2 }
private val linus: ID = List(16) { 3 }

private val adaProfile = profile("Ada")
private val graceProfile = profile("Grace")

private val profiles = mapOf(
ada.hexEncodedString() to adaProfile,
grace.hexEncodedString() to graceProfile,
)

private fun profile(name: String) = UserProfile(
displayName = name,
socialAccounts = emptyList(),
phoneNumber = null,
email = null,
)

private fun typing(userId: ID, atMillis: Long) =
ActiveTypist(userId, Instant.fromEpochMilliseconds(atMillis))

@Test
fun `a group lists its typists oldest first`() {
val avatars = typingAvatars(
// Out of order on purpose: a set carries no order, so `since` has to supply it.
typists = setOf(typing(linus, 300), typing(ada, 100), typing(grace, 200)),
chatType = ChatType.GROUP,
profiles = profiles,
)

// Linus has no profile yet, and is still listed so the fallback can hold his place.
assertEquals(
listOf(
TypingAvatar(ada, adaProfile),
TypingAvatar(grace, graceProfile),
TypingAvatar(linus, null),
),
avatars,
)
}

@Test
fun `a DM with a typist lists nobody`() {
val typists = setOf(typing(ada, 100))

assertTrue(typingAvatars(typists, ChatType.CONTACT_DM, profiles).isEmpty())
assertTrue(typingAvatars(typists, ChatType.TIP_DM, profiles).isEmpty())
}

@Test
fun `a typist who stops typing drops out`() {
val both = setOf(typing(ada, 100), typing(grace, 200))
assertEquals(
listOf(ada, grace),
typingAvatars(both, ChatType.GROUP, profiles).map { it.userId },
)

val adaStopped = both.filterNot { it.userId == ada }.toSet()
assertEquals(
listOf(grace),
typingAvatars(adaStopped, ChatType.GROUP, profiles).map { it.userId },
)
}

@Test
fun `a typist keeps their key when their profile resolves`() {
// The indicator keys its lazy items on this; a key that moved when the profile landed
// would animate the avatar out and back in mid-typing.
val before = TypingAvatar(ada, profile = null)
val after = TypingAvatar(ada, adaProfile)

assertEquals(before.key, after.key)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,9 @@ private fun BoxWithConstraintsScope.InitialsText(displayName: String) {
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString("")
.ifEmpty { "?" }
}
// A name with no letters leaves the gradient on its own, as iOS does, rather than a "?".
if (initials.isEmpty()) return
val fontSize = with(LocalDensity.current) {
(min(maxWidth, maxHeight) * 0.38f).toSp()
}
Expand Down
Loading
Loading