diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 295ce4aad7..3de4ba15df 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -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() @@ -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) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt index 20230ac7e8..883d485b7f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt @@ -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 diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt index 04f53c6ea9..8550fefe3f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt @@ -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 @@ -26,6 +35,8 @@ 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 @@ -33,6 +44,9 @@ 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 @@ -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 @@ -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() diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt index c4fa9c7714..737500b85e 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt @@ -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() + 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 diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index a9d947f472..49e3992fea 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -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")) diff --git a/apps/flipcash/features/chat-list/build.gradle.kts b/apps/flipcash/features/chat-list/build.gradle.kts new file mode 100644 index 0000000000..978e03ad2a --- /dev/null +++ b/apps/flipcash/features/chat-list/build.gradle.kts @@ -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) +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt new file mode 100644 index 0000000000..d593115685 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -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() + 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() }, + ) + }, + ) +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt new file mode 100644 index 0000000000..41095c5c2e --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -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() + 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) } + }, + ) + }, + ) +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt new file mode 100644 index 0000000000..c118e2e065 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt @@ -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, +) diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt new file mode 100644 index 0000000000..c053196da3 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt @@ -0,0 +1,40 @@ +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.LobbyRequests +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 [ChatsViewModel]. */ +@HiltViewModel +internal class HiltChatsViewModel @Inject constructor( + exchange: Exchange, + chatCoordinator: ChatCoordinator, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + resources: ResourceHelper, + dispatchers: DispatcherProvider, + archiveStore: ChatArchiveStore, + lobbyRequests: LobbyRequests, + hostLobbies: HostLobbies, + paymentRequests: PaymentRequests, +) : ChatsViewModel( + exchange = exchange, + chatCoordinator = chatCoordinator, + userManager = userManager, + tokenCoordinator = tokenCoordinator, + resources = resources, + dispatchers = dispatchers, + archiveStore = archiveStore, + lobbyRequests = lobbyRequests, + hostLobbies = hostLobbies, + paymentRequests = paymentRequests, +) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt similarity index 75% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt index 26439d64e6..ada9ae2955 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.content_description_unarchiveChat +import com.flipcash.core.resources.title_noArchivedChats import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -16,68 +19,36 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -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.ArchivedChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.components.SwipeRevealGroup import com.getcode.ui.components.rememberSwipeRevealGroup import com.getcode.ui.theme.CodeScaffold -/** - * 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() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current - - ArchivedChatsContent( - chats = state.chats.dataOrNull.orEmpty(), - isLoaded = state.chats.isLoaded(), - onBack = { navigator.pop() }, - onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, - onUnarchive = viewModel::unarchive, - ) -} - -@Composable -private fun ArchivedChatsContent( +internal fun ArchivedChatsContent( chats: List, isLoaded: Boolean, - onBack: () -> Unit, onOpen: (ChatId) -> Unit, onUnarchive: (ChatId) -> Unit, + topBar: @Composable () -> Unit, ) { // One row open at a time, as on the Chats list. val revealGroup = rememberSwipeRevealGroup() CodeScaffold( - topBar = { - AppBarWithTitle( - title = stringResource(R.string.title_archived), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = onBack, - ) - }, + topBar = topBar, ) { padding -> // One list for the rows and the empty state alike, so unarchiving the last chat fades its // row out and the message in, rather than swapping the whole list for the message at once. @@ -96,7 +67,7 @@ private fun ArchivedChatsContent( ) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.title_noArchivedChats), + text = stringResource(Res.string.title_noArchivedChats), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -131,7 +102,7 @@ private fun UnarchiveSwipeRow( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val label = stringResource(R.string.content_description_unarchiveChat) + val label = stringResource(Res.string.content_description_unarchiveChat) SwipeActionRow( actions = listOf( SwipeAction( diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt similarity index 82% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index a955a3dfcd..4ad16825b2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -1,10 +1,27 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_undo +import com.flipcash.core.resources.content_description_archiveChat +import com.flipcash.core.resources.content_description_archivedUnread +import com.flipcash.core.resources.content_description_changeMute +import com.flipcash.core.resources.content_description_muteChat +import com.flipcash.core.resources.description_noChatsYet +import com.flipcash.core.resources.ic_bubble_outline +import com.flipcash.core.resources.message_chatArchived +import com.flipcash.core.resources.title_allChatsArchived +import com.flipcash.core.resources.title_archived +import com.flipcash.core.resources.title_filterAll +import com.flipcash.core.resources.title_filterGroups +import com.flipcash.core.resources.title_filterUnread +import com.flipcash.core.resources.title_noChatsYet +import com.flipcash.core.resources.title_noGroupChats +import com.flipcash.core.resources.title_noUnreadChats +import com.flipcash.core.resources.title_tabChat import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.ScrollableDefaults @@ -12,12 +29,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Archive import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -62,28 +79,20 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -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.core.navigation.LocalTabBarPadding import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tipping.internal.ChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.features.tipping.R import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.rememberIsMuted -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.toast.LocalFloatingToastHost -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.TopScrollEdge import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow @@ -104,24 +113,30 @@ import dev.chrisbanes.haze.rememberHazeState * [ChipRevealConnection] for how a pull settles. */ @Composable -fun ChatsScreen() { - val viewModel = hiltViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current - +fun ChatsScreenContent( + state: ChatsViewModel.State, + onSelectFilter: (ChatListFilter) -> Unit, + onRevealChips: () -> Unit, + onOpenArchived: () -> Unit, + onOpenChat: (ConversationReference) -> Unit, + onMute: (ConversationReference) -> Unit, + onArchive: (ConversationReference) -> Unit, + topBar: @Composable () -> Unit, +) { val chats = state.chats // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips // shown or not) survives opening a chat and coming back. val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (state.showsChips) 0 else 1) val flingBehavior = ScrollableDefaults.flingBehavior() - val chipRevealConnection = remember(listState, flingBehavior, viewModel) { + val currentState by rememberUpdatedState(state) + val chipRevealConnection = remember(listState, flingBehavior) { ChipRevealConnection( listState = listState, flingBehavior = flingBehavior, - isShown = { viewModel.stateFlow.value.showsChips }, + isShown = { currentState.showsChips }, // Nothing to filter while loading or with no chats at all. - canReveal = { viewModel.stateFlow.value.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, - onRevealed = viewModel::revealChips, + canReveal = { currentState.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, + onRevealed = onRevealChips, ) } val visible = state.visibleChats @@ -139,18 +154,14 @@ fun ChatsScreen() { // Restored on the chips (after process death, say) with the ViewModel starting over: they are // on screen, so they count as revealed rather than being parked by the next scroll. LaunchedEffect(listState) { - if (listState.firstVisibleItemIndex == 0 && !viewModel.stateFlow.value.showsChips) { - viewModel.revealChips() + if (listState.firstVisibleItemIndex == 0 && !currentState.showsChips) { + onRevealChips() } } // One row open at a time: swiping another row closes the one left revealed. val revealGroup = rememberSwipeRevealGroup() - // The root toast host, so the undo rises out of the tab bar on its glass. - val toasts = LocalFloatingToastHost.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current - val archivedMessage = stringResource(R.string.message_chatArchived) - val undoLabel = stringResource(R.string.action_undo) val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() val hazeState = rememberHazeState() @@ -159,20 +170,7 @@ fun ChatsScreen() { // The list runs the full height and passes under the title bar, where it blurs into the bar // (TopScrollEdge) as iOS's soft scroll edge does, rather than being cut off at the bar. barPlacement = ScaffoldBarPlacement.Overlay, - topBar = { - AppBarWithTitle( - title = stringResource(R.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) } - }, - ) - }, + topBar = topBar, ) { barPadding -> Box(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -215,11 +213,11 @@ fun ChatsScreen() { if (!state.showsChips && listState.chipVisibleFraction() < 1f) { // Hidden under the bar (a stray tap, or a screen reader reaching it): // bring the row on rather than switching filters out of sight. - viewModel.revealChips() + onRevealChips() scope.launch { listState.animateScrollToItem(0) } } else if (filter != state.filter) { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) - viewModel.selectFilter(filter) + onSelectFilter(filter) } }, ) @@ -238,7 +236,7 @@ fun ChatsScreen() { ) { ArchivedRow( count = state.projection.archivedRowCount, - onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, + onClick = onOpenArchived, ) } } @@ -264,36 +262,19 @@ fun ChatsScreen() { .then(parkFiller.tracked("empty_filter")) .fillParentMaxSize(), message = when (state.filter) { - ChatListFilter.Unread -> R.string.title_noUnreadChats - ChatListFilter.Groups -> R.string.title_noGroupChats - ChatListFilter.All -> R.string.title_allChatsArchived + ChatListFilter.Unread -> Res.string.title_noUnreadChats + ChatListFilter.Groups -> Res.string.title_noGroupChats + ChatListFilter.All -> Res.string.title_allChatsArchived }, ) } else -> tipChatItems( chats = visible, - onClick = { chat -> - navigator.push( - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) - ) - }, + onClick = onOpenChat, // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. - 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) - } - } - }, + onMute = onMute, + onArchive = onArchive, revealGroup = revealGroup, parkFiller = parkFiller, ) @@ -332,13 +313,13 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { ) { Image( modifier = Modifier.size(CodeTheme.dimens.grid.x16), - painter = painterResource(R.drawable.ic_bubble_outline), + painter = painterResource(Res.drawable.ic_bubble_outline), contentDescription = null, colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), ) Text( - text = stringResource(R.string.title_noChatsYet), + text = stringResource(Res.string.title_noChatsYet), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, textAlign = TextAlign.Center, @@ -346,7 +327,7 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { Text( modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.description_noChatsYet), + text = stringResource(Res.string.description_noChatsYet), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -409,10 +390,10 @@ private fun ChatSwipeRow( content: @Composable () -> Unit, ) { val muteLabel = stringResource( - if (isMuted) R.string.content_description_changeMute - else R.string.content_description_muteChat + if (isMuted) Res.string.content_description_changeMute + else Res.string.content_description_muteChat ) - val archiveLabel = stringResource(R.string.content_description_archiveChat) + val archiveLabel = stringResource(Res.string.content_description_archiveChat) SwipeActionRow( actions = listOf( SwipeAction( @@ -478,18 +459,18 @@ private fun ChatFilterRow( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { FilterChip( - label = stringResource(R.string.title_filterAll), + label = stringResource(Res.string.title_filterAll), selected = selected == ChatListFilter.All, onClick = { onSelect(ChatListFilter.All) }, ) FilterChip( - label = stringResource(R.string.title_filterUnread), + label = stringResource(Res.string.title_filterUnread), selected = selected == ChatListFilter.Unread, onClick = { onSelect(ChatListFilter.Unread) }, count = unreadCount, ) FilterChip( - label = stringResource(R.string.title_filterGroups), + label = stringResource(Res.string.title_filterGroups), selected = selected == ChatListFilter.Groups, onClick = { onSelect(ChatListFilter.Groups) }, count = groupsCount, @@ -504,9 +485,9 @@ private fun ChatFilterRow( @Composable private fun ArchivedRow(count: Int, onClick: () -> Unit) { val description = if (count > 0) { - stringResource(R.string.content_description_archivedUnread, count) + stringResource(Res.string.content_description_archivedUnread, count) } else { - stringResource(R.string.title_archived) + stringResource(Res.string.title_archived) } val color = CodeTheme.colors.textSecondary Row( @@ -538,7 +519,7 @@ private fun ArchivedRow(count: Int, onClick: () -> Unit) { } Text( modifier = Modifier.weight(1f), - text = stringResource(R.string.title_archived), + text = stringResource(Res.string.title_archived), style = CodeTheme.typography.textMedium, color = color, ) @@ -559,7 +540,7 @@ private fun ArchivedRow(count: Int, onClick: () -> Unit) { /** A filter with nothing under it, centred in the space it is given (the list viewport). */ @Composable -private fun EmptyFilterState(@StringRes message: Int, modifier: Modifier = Modifier) { +private fun EmptyFilterState(message: StringResource, modifier: Modifier = Modifier) { Box(modifier = modifier, contentAlignment = Alignment.Center) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt similarity index 96% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index 7db4036db4..a3bc0568b5 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -13,7 +13,6 @@ import com.flipcash.shared.chat.archivedChatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.PaymentRequests @@ -24,8 +23,8 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** Backs the Archived screen: the archived chats, newest first, and unarchive. */ -@HiltViewModel -internal class ArchivedChatsViewModel @Inject constructor( +/** Android injects it through `HiltArchivedChatsViewModel`. */ +internal open class ArchivedChatsViewModel @Inject constructor( private val exchange: Exchange, private val chatCoordinator: ChatCoordinator, private val archiveStore: ChatArchiveStore, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt similarity index 94% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index cef14e4572..6dba8a2010 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -2,7 +2,6 @@ package com.flipcash.app.tipping.internal import com.getcode.opencode.exchange.Exchange import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.data.Loadable import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator @@ -39,9 +38,11 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -/** Backs the "Chats" tab: the user's tip DMs and groups. */ -@HiltViewModel -internal class ChatsViewModel @Inject constructor( +/** + * Backs the "Chats" tab: the user's tip DMs and groups. Android injects it through + * `HiltChatsViewModel`; desktop builds it from its own graph. + */ +open class ChatsViewModel @Inject constructor( private val exchange: Exchange, private val chatCoordinator: ChatCoordinator, userManager: UserManager, @@ -198,7 +199,7 @@ internal class ChatsViewModel @Inject constructor( .launchIn(viewModelScope) } - internal companion object { + companion object { internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { is Event.ChatsUpdated -> { state -> @@ -220,14 +221,3 @@ internal fun List.inOrderOf(ids: List): List id to index } return sortedBy { rank[it.chatId.toString()] ?: Int.MAX_VALUE } } - -/** - * The hosted groups with someone waiting, each with its waiting list, the group whose newest - * request is the most recent first. - */ -internal fun hostedRequests( - hosted: Collection, - lobbies: Map>, -): List>> = hosted - .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } - .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt similarity index 100% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt similarity index 100% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt similarity index 92% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index af89468733..123b0ff80d 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,5 +1,9 @@ package com.flipcash.app.tipping.internal.components +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.subtitle_peopleWantToJoin +import com.flipcash.core.resources.subtitle_wantsToJoin +import com.flipcash.core.resources.title_unnamedUser import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,13 +14,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ChatListRow @@ -120,14 +123,14 @@ private fun joinRequestText(waiting: List): String? { return when { waiting.isEmpty() -> null only != null -> stringResource( - R.string.subtitle_wantsToJoin, + Res.string.subtitle_wantsToJoin, chatterName( only.userProfile.displayName, only.userProfile.username, - stringResource(R.string.title_unnamedUser), + stringResource(Res.string.title_unnamedUser), ), ) - else -> pluralStringResource(R.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) + else -> pluralStringResource(Res.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) } } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt similarity index 88% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index 6b1e64517f..53c4249fad 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -1,9 +1,6 @@ package com.flipcash.app.tipping.internal -import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType @@ -14,7 +11,7 @@ import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Rate -import com.getcode.util.resources.FakeResourceHelper +import com.getcode.util.resources.ResourceHelper import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,8 +21,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Instant @@ -38,8 +39,13 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class ChatsViewModelArchiveTest { - @get:Rule - var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + private val mainDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(mainDispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() private val chat = ChatSummary( metadata = ChatMetadata( @@ -56,7 +62,7 @@ class ChatsViewModelArchiveTest { @Test fun `archiving the last main chat never shows the no-chats prompt`() = - runTest(mainCoroutineRule.dispatcher) { + runTest(mainDispatcher) { val feeds = MutableStateFlow(ChatFeeds(main = listOf(chat), archived = emptyList())) val chatCoordinator = mockk(relaxed = true).also { every { it.feedWithArchived(*anyVararg()) } returns feeds @@ -75,7 +81,7 @@ class ChatsViewModelArchiveTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(R::class.java), + resources = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt similarity index 90% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt index 7fa71bced8..e390a0b047 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -1,9 +1,6 @@ package com.flipcash.app.tipping.internal -import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata @@ -19,7 +16,7 @@ import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint -import com.getcode.util.resources.FakeResourceHelper +import com.getcode.util.resources.ResourceHelper import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -29,8 +26,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Instant @@ -42,8 +43,13 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class ChatsViewModelRatesTest { - @get:Rule - var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + private val mainDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(mainDispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() private val cad = Rate(fx = 1.4, currency = CurrencyCode.CAD) private val gbp = Rate(fx = 0.8, currency = CurrencyCode.GBP) @@ -75,7 +81,7 @@ class ChatsViewModelRatesTest { @Test fun `a payment preview redraws when the sent currency's rate lands after the display rate`() = - runTest(mainCoroutineRule.dispatcher) { + runTest(mainDispatcher) { val preferred = MutableStateFlow(cad) val rates = MutableStateFlow(mapOf(CurrencyCode.CAD to cad)) val exchange = mockk(relaxed = true).also { @@ -99,7 +105,7 @@ class ChatsViewModelRatesTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(R::class.java), + resources = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt similarity index 96% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt index 52a3198eb1..c5da4ba794 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt @@ -5,10 +5,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatListFilter import com.flipcash.shared.chat.ChatListProjection import com.flipcash.shared.chat.ui.ConversationReference -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.Test class ChatsViewModelStateTest { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt similarity index 97% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt index 45a647863c..ac47e036a3 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt @@ -1,7 +1,7 @@ package com.flipcash.app.tipping.internal -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.Test class ChipSettleTest { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt similarity index 95% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt index 2403867e06..029995176e 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt @@ -5,9 +5,9 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.solana.keys.PublicKey -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.Test import kotlin.time.Instant /** What a hosted group's row draws while someone waits to join it, and where it sits. */ diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt new file mode 100644 index 0000000000..0056894eb8 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt @@ -0,0 +1,14 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.libs.coroutines.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler + +internal class TestDispatchers(scheduler: TestCoroutineScheduler) : DispatcherProvider { + private val dispatcher = StandardTestDispatcher(scheduler) + + override val IO: CoroutineDispatcher = dispatcher + override val Main: CoroutineDispatcher = dispatcher + override val Default: CoroutineDispatcher = dispatcher +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt new file mode 100644 index 0000000000..638bb10a30 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember + +/** + * The hosted groups with someone waiting, each with its waiting list, the group whose newest + * request is the most recent first. + */ +internal fun hostedRequests( + hosted: Collection, + lobbies: Map>, +): List>> = hosted + .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } + .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt b/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt index 74b889aa96..c6e884e88b 100644 --- a/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt +++ b/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt @@ -56,16 +56,6 @@ data class ChattingGridItem( val isHost: Boolean, ) -/** - * What a chatter is called under their portrait: their display name, else their handle, else - * [fallback]. Blank counts as missing, since the server sends an empty display name for a profile - * that has none. - */ -fun chatterName(displayName: String?, username: String?, fallback: String): String = - displayName?.takeIf { it.isNotBlank() } - ?: username?.takeIf { it.isNotBlank() }?.let { "@$it" } - ?: fallback - /** * Who has been talking in a public group: a "Chatting" heading over portraits four to a row, the * host badged. Draws nothing for an empty list, heading included. diff --git a/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt b/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt new file mode 100644 index 0000000000..4aa0a7a0ef --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt @@ -0,0 +1,11 @@ +package com.flipcash.shared.common.ui.profile + +/** + * What a chatter is called under their portrait: their display name, else their handle, else + * [fallback]. Blank counts as missing, since the server sends an empty display name for a profile + * that has none. + */ +fun chatterName(displayName: String?, username: String?, fallback: String): String = + displayName?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: fallback diff --git a/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt b/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt index 978d9a48c5..f1e8b30d42 100644 --- a/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt +++ b/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt @@ -32,11 +32,12 @@ object StartupContract { /** `SessionListener` implementations desktop must bind as well. Fully qualified class names. */ val sharedSessionListeners: Set = setOf( "com.flipcash.app.tokens.TokenCoordinator", + // Without it the feed never syncs, so the chat list stays empty. + "com.flipcash.shared.chat.internal.RealChatCoordinator", ) /** `SessionListener` implementations only Android binds, with why desktop goes without. */ val androidOnlySessionListeners: Map = mapOf( - "com.flipcash.shared.chat.internal.RealChatCoordinator" to "chat is not on desktop", "com.flipcash.app.contacts.ContactCoordinator" to "contacts are not on desktop", ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 455720743c..6ac7d39f63 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -197,6 +197,7 @@ include( ":apps:flipcash:features:search", ":apps:flipcash:features:user-profile", ":apps:flipcash:features:userflags", + ":apps:flipcash:features:chat-list", ":apps:flipcash:features:tipping", // Internal libs