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
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:notifications"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
debugImplementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:onramp:coinbase"))
implementation(project(":apps:flipcash:shared:onramp:deeplinks"))
implementation(libs.phantom.connect) {
Expand Down
8 changes: 7 additions & 1 deletion apps/flipcash/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

<uses-permission android:name="com.getcode.dev.READ_ACCOUNT" />
<application>

<receiver
android:name="com.flipcash.app.debug.ReactionAlertDebugReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.flipcash.debug.REACTION_ALERT" />
</intent-filter>
</receiver>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.flipcash.app.debug

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ReactionUpdate
import com.flipcash.shared.chat.alerts.ReactionActorProfiles
import com.flipcash.shared.chat.alerts.ReactionAlertController
import com.flipcash.shared.chat.alerts.ReactionAlertEvent
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

/**
* Debug builds only: fires canned in-app reaction alerts so the banner can be driven from adb.
*
* adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind reaction
* adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind boost
* adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind stack
*
* The reactor is the first known chat member whose name is `--es name` (default "XCTests"), and the
* photo comes through the same lookup the real stream uses. `stack` sends four reactors one after
* another so the card grows from 1 to 4 people.
*/
class ReactionAlertDebugReceiver : BroadcastReceiver() {

@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun controller(): ReactionAlertController
fun members(): ChatMemberDataSource
fun profiles(): ReactionActorProfiles
}

override fun onReceive(context: Context, intent: Intent) {
val deps = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java)
val controller = deps.controller()
val members = deps.members()
val profiles = deps.profiles()
val kind = intent.getStringExtra("kind") ?: "reaction"
val name = intent.getStringExtra("name") ?: "XCTests"
val pending = goAsync()
scope.launch {
try {
fire(kind, name, controller, members, profiles)
} finally {
pending.finish()
}
}
}

private suspend fun fire(
kind: String,
name: String,
controller: ReactionAlertController,
members: ChatMemberDataSource,
profiles: ReactionActorProfiles,
) {
val all = members.observeAll().first()
val (chatHex, member) = all.entries.firstNotNullOfOrNull { (hex, list) ->
list.firstOrNull { it.userProfile.displayName == name }?.let { hex to it }
} ?: all.entries.firstNotNullOf { (hex, list) -> list.firstOrNull()?.let { hex to it } }
val chat = ChatId(chatHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray())
val resolved = profiles.resolve(member.userId)
val actorName = resolved?.displayName?.takeIf { it.isNotBlank() } ?: member.userProfile.displayName
val photo = member.userProfile.profilePicture ?: resolved?.profilePicture

fun event(who: List<Byte>, whoName: String, whoPhoto: Boolean, emoji: String, action: ReactionUpdate.Action, cents: Long? = null) =
ReactionAlertEvent(
chatId = chat,
messageId = 1,
emoji = emoji,
actor = who,
action = action,
boostDeltaCents = cents,
actorName = whoName,
chatName = "Moony",
quote = "Me! I'll send my \$40 tonight",
actorPhoto = if (whoPhoto) photo else null,
)

when (kind) {
"boost" -> controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.BOOSTED, 500))
"stack" -> {
val crowd = listOf("Chloe" to "❤️", "Sam" to "😂", "Jo" to "👏")
controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.ADDED))
crowd.forEachIndexed { i, (n, e) ->
delay(900)
controller.offer(event(listOf(i.toByte(), 9), n, false, e, ReactionUpdate.Action.ADDED))
}
}
else -> controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.ADDED))
}
}

private companion object {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ internal fun App(
TransactionNotificationPromptHost(viewModel.notificationPrompt)
CoinbaseOnRampHandler {
AppContent(
reactionAlerts = viewModel.reactionAlerts,
codeNavigator = codeNavigator,
resultStateRegistry = resultStateRegistry,
barManager = barManager,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal class HomeViewModel @Inject constructor(
featureFlagController: FeatureFlagController,
userFlags: UserFlagsCoordinator,
val notificationPrompt: com.flipcash.app.tokens.TransactionNotificationPrompt,
val reactionAlerts: com.flipcash.shared.chat.alerts.ReactionAlertController,
) : ViewModel() {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ import androidx.navigation3.scene.SinglePaneSceneStrategy
import com.flipcash.app.cardexpand.CardExpansionController
import com.flipcash.app.cardexpand.LocalCardExpansion
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.shared.chat.alerts.LocalReactionAlerts
import com.flipcash.shared.chat.alerts.ReactionAlertAction
import com.flipcash.shared.chat.alerts.ReactionAlertController
import com.flipcash.shared.chat.ui.ReactionAlertHost
import kotlinx.coroutines.flow.filterIsInstance
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.LocalTabBarVisibility
import com.flipcash.app.core.navigation.NavBarButton
Expand Down Expand Up @@ -108,6 +114,7 @@ internal fun AppContent(
resultStateRegistry: NavResultStateRegistry,
barManager: BarManager,
cardExpansion: CardExpansionController,
reactionAlerts: ReactionAlertController,
deepLink: () -> DeepLink?,
onPendingAction: (DeeplinkAction) -> Unit = {},
canSwitchAccounts: Boolean = false,
Expand Down Expand Up @@ -141,6 +148,21 @@ internal fun AppContent(
}
UpdateAvailableToast(navigator = codeNavigator, barVisibility = barVisibility, toasts = toasts)

// A reaction pill tapped outside its chat opens the chat at the message. One tapped inside the
// open chat is handled by the chat screen, which scrolls and pulses instead.
LaunchedEffect(reactionAlerts, codeNavigator) {
reactionAlerts.actions
.filterIsInstance<ReactionAlertAction.OpenChat>()
.collect { action ->
codeNavigator.push(
AppRoute.Messaging.Chat(
identifier = ChatIdentifier.ByChatId(action.chatId),
jumpToMessageId = action.messageId,
)
)
}
}

// A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every
// tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the
// chat list scrolled back to the top. Hold each tab home's ViewModels and scroll state outside the
Expand All @@ -167,6 +189,7 @@ internal fun AppContent(
LocalCardExpansion provides cardExpansion,
LocalTabBarVisibility provides tabBarVisibility,
LocalFloatingToastHost provides toasts,
LocalReactionAlerts provides reactionAlerts,
) {
Box(modifier = Modifier.fillMaxSize()) {
// Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it.
Expand Down Expand Up @@ -301,6 +324,9 @@ internal fun AppContent(
// The search button's glass growing into Search's field, over everything below it.
searchMorph.Overlay(hazeState)

// Top of the app, over the status bar and the camera cutout, so it shows on any screen.
ReactionAlertHost(controller = reactionAlerts, hazeState = hazeState)

// The expanded currency-info overlay is hosted INSIDE the wallet nav entry (see CardExpandHost),
// not here — so a pushed Give/Convert/Withdraw naturally covers it (correct z-order) and the deck
// reorganises behind it. The controller is provided app-root (above) so its fly-state survives the
Expand Down
8 changes: 8 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1692,4 +1692,12 @@
<item quantity="one">You sent %1$s to %2$d person</item>
<item quantity="other">You sent %1$s to %2$d people</item>
</plurals>
<string name="alert_reaction_title">%1$s reacted to your message</string>
<string name="alert_boost_title">%1$s boosted their %2$s</string>
<plurals name="alert_several_title">
<item quantity="one">%1$s and %2$d other reacted</item>
<item quantity="other">%1$s and %2$d others reacted</item>
</plurals>
<string name="alert_someone">Someone</string>
<string name="alert_thisChat">This chat ↓</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import com.flipcash.app.updates.LocalAppUpdater
import com.flipcash.features.messenger.R
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import com.flipcash.shared.chat.alerts.LocalReactionAlerts
import com.flipcash.shared.chat.alerts.ReactionAlertAction
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -147,6 +150,19 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile))
}

// A reaction pill tapped while this chat is open asks to scroll to its message and pulse it.
val reactionAlerts = LocalReactionAlerts.current
val currentChatId by rememberUpdatedState(state.chatId)
LaunchedEffect(reactionAlerts, viewModel) {
reactionAlerts?.actions
?.filterIsInstance<ReactionAlertAction.ScrollTo>()
?.collect { action ->
if (action.chatId == currentChatId) {
viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId))
}
}
}

// Here rather than in the action handler because the lookup comes first: the tap only knows a
// handle, and where it leads is the view model's to decide once the handle has an answer.
LaunchedEffect(viewModel) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ internal fun MessageList(
// True while the list is being carried to a new newest row, so the jump button stays down.
var autoScrolling by remember { mutableStateOf(false) }
HandleMessageReads(listState, messages, enabled = hasPositioned)
ReportVisibleToReactionAlerts(state.chatId, listState, messages)
// The first drag or fling after the list has settled. Not isScrollInProgress, which is
// also true through the animated and instant scrolls the list makes itself (opening,
// auto-scroll, the jump button): only a finger starts a DragInteraction, and a fling
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.alerts.LocalReactionAlerts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -61,3 +64,34 @@ internal fun HandleMessageReads(
}
}
}

/**
* Tells the reaction alerts which messages are on screen, so a reaction on one of them animates its
* bubble instead of raising a pill. Cleared when the chat leaves composition.
*/
@Composable
internal fun ReportVisibleToReactionAlerts(
chatId: ChatId?,
listState: LazyListState,
messages: LazyPagingItems<ChatListItem>,
) {
val alerts = LocalReactionAlerts.current ?: return
if (chatId == null) return
DisposableEffect(chatId, alerts) {
onDispose { alerts.clearOnScreen(chatId) }
}
LaunchedEffect(chatId, listState, messages, alerts) {
snapshotFlow {
val count = messages.itemCount
buildSet {
for (info in listState.layoutInfo.visibleItemsInfo) {
if (info.index !in 0 until count) continue
val message = messages.peek(info.index) as? ChatListItem.TranscriptMessage ?: continue
add(message.messageId)
}
}
}
.distinctUntilChanged()
.collect { visible -> alerts.reportOnScreen(chatId, visible) }
}
}
Loading
Loading