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
6 changes: 6 additions & 0 deletions apps/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ kotlin {
}

metro {
// Dagger ignores Kotlin defaults, so shared classes like RealChatCoordinator list defaulted
// parameters that Android always injects. Metro's default treats those as optional and orders
// the graph without them, which hands the factory a null provider at startup.
optionalBindingBehavior.set(dev.zacsweers.metro.gradle.OptionalBindingBehavior.REQUIRE_OPTIONAL_BINDING)
interop {
includeJavax()
includeDagger()
Expand Down Expand Up @@ -45,6 +49,8 @@ dependencies {
implementation(project(":apps:flipcash:shared:device-link"))
implementation(project(":apps:flipcash:shared:blob"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:features:chat-list"))
implementation(project(":libs:emojis"))
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.dagger)
implementation(libs.grpc.okhttp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ import javax.inject.Singleton
FlipcashDesktopChannelModule::class,
DesktopModule::class,
com.flipcash.app.blob.JvmBlobModule::class,
com.flipcash.shared.chat.inject.ChatModule::class,
com.flipcash.shared.chat.inject.RecentReactionsOwnerModule::class,
],
)
interface DesktopGraph {
val walletViewModel: WalletViewModel
val chatsViewModel: com.flipcash.app.tipping.internal.ChatsViewModel
val selectTokenViewModel: SelectTokenViewModel
val exchange: Exchange
val resources: ResourceHelper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import com.flipcash.app.blob.FilePickedPhotos
import com.flipcash.app.blob.JvmUploadImageCache
import com.flipcash.app.blob.PickedPhotos
import com.flipcash.app.blob.UploadImageCache
import com.flipcash.shared.chat.AppForeground
import com.flipcash.shared.chat.ChatNotifications
import com.flipcash.shared.chat.HostInvites
import com.flipcash.shared.chat.PaymentRequests
import com.flipcash.app.persistence.sources.IncomingMessageOpener
import com.flipcash.services.chat.GroupKeyEnvelopeStore
import com.flipcash.services.chat.ChatKeySource
import com.flipcash.services.chat.GroupKeySource
import com.flipcash.services.controllers.ChatController
import com.flipcash.shared.chat.media.ChatMediaLocation
import com.flipcash.shared.chat.media.DirectoryChatMediaLocation
import com.flipcash.shared.chat.media.ImageLoaderSentImageCache
Expand All @@ -26,13 +35,18 @@ import com.flipcash.app.userflags.UserFlagsFiles
import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.chatcipher.ChatCipher
import com.getcode.chatcipher.DefaultChatCipher
import com.getcode.chatcipher.DefaultInviteCipher
import com.getcode.chatcipher.InviteCipher
import com.getcode.opencode.providers.SessionListener
import com.getcode.opencode.providers.TokenMetadataProvider
import com.getcode.util.locale.LocaleHelper
import com.getcode.util.resources.ComposeResourceHelper
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.network.NetworkConnectivityListener
import com.getcode.utils.network.NetworkObserverStub
import com.getcode.libs.emojis.reactions.DataStoreRecentReactionsStore
import com.getcode.libs.emojis.reactions.RecentReactionsOwner
import com.getcode.libs.emojis.reactions.RecentReactionsStore
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
Expand Down Expand Up @@ -70,11 +84,14 @@ object DesktopModule {
override val IO: CoroutineDispatcher = Dispatchers.IO
}

// ChatCryptoModule provides this from its companion object, which Metro's Dagger interop
// ChatCryptoModule provides these from its companion object, which Metro's Dagger interop
// does not read from a precompiled module.
@Provides
fun chatCipher(): ChatCipher = DefaultChatCipher

@Provides
fun inviteCipher(): InviteCipher = DefaultInviteCipher

@Provides
fun analytics(): FlipcashAnalytics = FlipcashAnalytics.None

Expand Down Expand Up @@ -121,6 +138,50 @@ object DesktopModule {
fun sentImageCache(): SentImageCache =
ImageLoaderSentImageCache { SingletonImageLoader.get(PlatformContext.INSTANCE) }

// A desktop window that is running counts as foreground; there is no process lifecycle to observe.
@Provides
@Singleton
fun appForeground(): AppForeground = object : AppForeground {
override val isForeground: Boolean = true
override fun observe(observer: androidx.lifecycle.DefaultLifecycleObserver) = Unit
}

// No system notifications on desktop yet, so there is nothing for chat to dismiss.
@Provides
@Singleton
fun chatNotifications(): ChatNotifications = ChatNotifications.None

// HostInvites keeps its @Inject on a secondary constructor, which Metro's Dagger interop does not read.
@Provides
@Singleton
fun hostInvites(
chatController: ChatController,
groupKeys: GroupKeySource,
keys: ChatKeySource,
cipher: InviteCipher,
): HostInvites = HostInvites(chatController, groupKeys, keys, cipher)

// RealChatCoordinator and MessagingDelegate take these as `T? = null`. Dagger sees them as required; Metro, with
// REQUIRE_OPTIONAL_BINDING (see build.gradle.kts), wants a binding for the nullable type itself.
@Provides
fun optionalHostInvites(value: HostInvites): HostInvites? = value

@Provides
fun optionalPaymentRequests(value: PaymentRequests): PaymentRequests? = value

@Provides
fun optionalIncomingOpener(value: IncomingMessageOpener): IncomingMessageOpener? = value

@Provides
fun optionalGroupKeyEnvelopes(value: GroupKeyEnvelopeStore): GroupKeyEnvelopeStore? = value

// emojis' ReactionsModule is a Dagger module that module does not run Dagger's compiler for on the JVM,
// so Metro cannot read it; this is its provider.
@Provides
@Singleton
fun recentReactionsStore(files: PreferencesFiles, owner: RecentReactionsOwner): RecentReactionsStore =
DataStoreRecentReactionsStore(files, owner)

@Provides
@Singleton
fun resources(): ResourceHelper = ComposeResourceHelper()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ class DesktopWiringTest {
assertSame(graph.sentImageCache, graph.sentImageCache, "SentImageCache")
}

@Test
fun `chat list resolves and its coordinator is a session listener`() {
// ChatsViewModel is the sidebar list's state holder; RealChatCoordinator in the listener set is
// what starts the feed sync on login, so a missing @IntoSet leaves the list empty without an error.
val viewModel = graph.chatsViewModel
assertTrue(viewModel is com.flipcash.app.tipping.internal.ChatsViewModel)
val coordinator = graph.sessionListeners.filterIsInstance<com.flipcash.shared.chat.internal.RealChatCoordinator>()
assertEquals(1, coordinator.size, "RealChatCoordinator in the SessionListener set")
}

/**
* The repository subscribes to `Events.OnLoggedIn` on the shared bus. Closing that key with
* REQUIRE_FLOW_IS_NOT_COLLECTING fails only while someone collects it; the subscription is
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ dependencies {
implementation(project(":apps:flipcash:features:search"))
implementation(project(":apps:flipcash:features:user-profile"))
implementation(project(":apps:flipcash:features:userflags"))
implementation(project(":apps:flipcash:features:chat-list"))
implementation(project(":apps:flipcash:features:tipping"))

implementation(project(":libs:crypto:solana"))
Expand Down
68 changes: 68 additions & 0 deletions apps/flipcash/features/chat-list/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
plugins {
alias(libs.plugins.flipcash.kmp.app.library)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.ksp)
}

kotlin {
android {
namespace = "${Gradle.flipcashNamespace}.features.chatlist"
}

sourceSets {
commonMain.dependencies {
implementation(project(":apps:flipcash:core"))
implementation(project(":apps:flipcash:core-ui"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:theme"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":services:flipcash"))
implementation(project(":services:opencode"))
implementation(project(":libs:coroutines"))
implementation(project(":ui:components"))
implementation(project(":ui:core"))
implementation(project(":ui:navigation"))
implementation(project(":ui:resources"))
implementation(project(":ui:theme"))

implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.animation)
implementation(libs.jetbrains.compose.ui)
implementation(libs.jetbrains.compose.material)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.material.icons.extended)
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.jetbrains.compose.ui.tooling.preview)
implementation(libs.bundles.haze)
implementation(libs.javax.inject)
}
androidMain.dependencies {
// What flipcash.android.feature supplied before the move to KMP.
implementation(libs.bundles.hilt)
implementation(project.dependencies.platform(libs.compose.bom))
implementation(libs.bundles.compose)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
}
getByName("androidHostTest").dependencies {
implementation(kotlin("test"))
implementation(libs.bundles.unit.testing)
implementation(libs.robolectric)
implementation(project(":ui:resources:test-fixtures"))
implementation(project(":libs:test-utils"))
}
}
}

dependencies {
add("kspAndroid", libs.hilt.android.compiler)
add("kspAndroid", libs.hilt.compiler)
add("kspJvm", libs.dagger.compiler)
add("jvmMainImplementation", libs.dagger)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// kmp-audit: hiltViewModel is Android-only; the screen content is ArchivedChatsContent in commonMain.
package com.flipcash.app.tipping

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.data.isLoaded
import com.flipcash.app.tipping.internal.HiltArchivedChatsViewModel
import com.flipcash.core.resources.Res
import com.flipcash.core.resources.title_archived
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.ui.components.AppBarWithTitle
import org.jetbrains.compose.resources.stringResource

/**
* The archived chats: the same row as the Chats list, sorted by last activity. A swipe on a row
* unarchives it. Opening a chat from here does not unarchive it.
*/
@Composable
fun ArchivedChatsScreen() {
val viewModel = hiltViewModel<HiltArchivedChatsViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current

ArchivedChatsContent(
chats = state.chats.dataOrNull.orEmpty(),
isLoaded = state.chats.isLoaded(),
onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) },
onUnarchive = viewModel::unarchive,
topBar = {
AppBarWithTitle(
title = stringResource(Res.string.title_archived),
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = { navigator.pop() },
)
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// kmp-audit: hiltViewModel is Android-only; the screen content is ChatsScreenContent in commonMain.
package com.flipcash.app.tipping

import androidx.compose.material.SnackbarResult
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Archive
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.tipping.internal.HiltChatsViewModel
import com.flipcash.core.resources.Res
import com.flipcash.core.resources.action_undo
import com.flipcash.core.resources.message_chatArchived
import com.flipcash.core.resources.title_tabChat
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.components.toast.LocalFloatingToastHost
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource

/**
* The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss
* affordance (the root nav bar is the chrome). The tip card lives on the "You" tab.
*
* Wires [ChatsScreenContent] to the Android view model, the navigator and the root toast host.
*/
@Composable
fun ChatsScreen() {
val viewModel = hiltViewModel<HiltChatsViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current
// The root toast host, so the undo rises out of the tab bar on its glass.
val toasts = LocalFloatingToastHost.current
val scope = rememberCoroutineScope()
val archivedMessage = stringResource(Res.string.message_chatArchived)
val undoLabel = stringResource(Res.string.action_undo)

ChatsScreenContent(
state = state,
onSelectFilter = viewModel::selectFilter,
onRevealChips = viewModel::revealChips,
onOpenArchived = { navigator.push(AppRoute.Messaging.ArchivedChats) },
onOpenChat = { chat ->
navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)))
},
onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) },
onArchive = { chat ->
viewModel.archive(chat.chatId)
// The row leaves the list at once, so offer to put it back.
if (toasts != null) {
scope.launch {
val result = toasts.show(
message = archivedMessage,
icon = Icons.Outlined.Archive,
actionLabel = undoLabel,
)
if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId)
}
}
},
topBar = {
AppBarWithTitle(
title = stringResource(Res.string.title_tabChat),
// Centred rather than flush-start: an empty leading slot reserves no width, so a
// Start title sits at the inset and reads as off-centre against the Add button.
titleAlignment = Alignment.CenterHorizontally,
// Node 9442:5779 — the only way to start a chat with someone who has never paid
// you. A pushed route rather than an in-place screen: this list is a tab home, and
// anything drawn inside it would leave the tab bar over the entry screen.
endContent = {
AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) }
},
)
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.flipcash.app.tipping.internal

import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatArchiveStore
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.HostLobbies
import com.flipcash.shared.chat.PaymentRequests
import com.getcode.opencode.exchange.Exchange
import com.getcode.util.resources.ResourceHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

/** Hilt entry point for the shared [ArchivedChatsViewModel]. */
@HiltViewModel
internal class HiltArchivedChatsViewModel @Inject constructor(
exchange: Exchange,
chatCoordinator: ChatCoordinator,
archiveStore: ChatArchiveStore,
userManager: UserManager,
tokenCoordinator: TokenCoordinator,
resources: ResourceHelper,
dispatchers: DispatcherProvider,
paymentRequests: PaymentRequests,
hostLobbies: HostLobbies,
) : ArchivedChatsViewModel(
exchange = exchange,
chatCoordinator = chatCoordinator,
archiveStore = archiveStore,
userManager = userManager,
tokenCoordinator = tokenCoordinator,
resources = resources,
dispatchers = dispatchers,
paymentRequests = paymentRequests,
hostLobbies = hostLobbies,
)
Loading
Loading