diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 77d36fc1bb..b198e191da 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -52,6 +52,7 @@ import com.flipcash.app.messenger.CurrencySendScreen import com.flipcash.app.messenger.internal.screens.profile.GroupInviteScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen +import com.flipcash.app.messenger.MuteChatsScreen import com.flipcash.app.messenger.SilenceUserScreen import com.flipcash.app.messenger.ProfileScreen import com.flipcash.app.messenger.SharePickerScreen @@ -185,6 +186,7 @@ internal fun appEntryProvider( annotatedEntry { key -> ShareProfileToChatsScreen(key) } annotatedEntry { key -> ShareCurrencyToChatsScreen(key) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } + annotatedEntry { key -> MuteChatsScreen(key.chatIds, key.chatTypes) } annotatedEntry { key -> SilenceUserScreen(key.chatId, key.userId, key.firstName, key.groupTitle) } diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 6fff7cc420..86418167d2 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1362,6 +1362,27 @@ Archive chat Unarchive chat + + Mark as Unread + Mark as Read + Unread + Read + Read All + Select Chats + Mute + Unmute + Archive + Unarchive + Close + More + %1$d Selected + + Chat archived + %1$d chats archived + + Mark All Chats as Read? + Everyone in these chats will see that you\'ve read their messages + Enter Flipcash username Enter the Flipcash username of the person you want to chat with diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt index 14ad8923b9..7291732991 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt @@ -565,6 +565,18 @@ sealed interface AppRoute : NavKey, Parcelable { com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + /** + * The mute sheet for several chats at once, from the chat list's select mode: the duration + * picked applies to every one of [chatIds], including any already muted. [chatTypes] pairs + * with [chatIds] by index, for the per-chat mute events. Presented like [MuteChat]. + */ + @Serializable + @Parcelize + data class MuteChats(val chatIds: List, val chatTypes: List) : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet + /** * How long to silence [userId] in the group [chatId]. Carries the words the sheet shows * ([firstName], [groupTitle]) so it needs no lookup before it can draw. Presented like diff --git a/apps/flipcash/features/chat-list/build.gradle.kts b/apps/flipcash/features/chat-list/build.gradle.kts index 978e03ad2a..cff96fb2fa 100644 --- a/apps/flipcash/features/chat-list/build.gradle.kts +++ b/apps/flipcash/features/chat-list/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { commonMain.dependencies { implementation(project(":apps:flipcash:core")) implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:common-ui")) @@ -42,6 +43,7 @@ kotlin { androidMain.dependencies { // What flipcash.android.feature supplied before the move to KMP. implementation(libs.bundles.hilt) + implementation(project(":libs:messaging")) implementation(project.dependencies.platform(libs.compose.bom)) implementation(libs.bundles.compose) } @@ -49,6 +51,7 @@ kotlin { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.mockk) + implementation(project(":apps:flipcash:shared:analytics:test-fixtures")) } getByName("androidHostTest").dependencies { implementation(kotlin("test")) @@ -56,6 +59,7 @@ kotlin { implementation(libs.robolectric) implementation(project(":ui:resources:test-fixtures")) implementation(project(":libs:test-utils")) + implementation(project(":apps:flipcash:shared:analytics:test-fixtures")) } } } diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt new file mode 100644 index 0000000000..9066dc5dcd --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt @@ -0,0 +1,95 @@ +package com.flipcash.app.tipping + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.ui.components.rememberSwipeRevealGroup +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The main list's trailing swipe: mute inside, archive outermost, and a full swipe archives. + * The gesture is the one a device sees: a slow partial drag that leaves both actions revealed, + * then a fast full swipe from there. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ChatSwipeRowTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private var archived = 0 + private var muted = 0 + + private fun show() { + composeRule.setContent { + FlipcashPreview(showBackground = true) { + ChatSwipeRow( + isMuted = false, + isUnread = false, + swipeEnabled = true, + onToggleRead = {}, + onMute = { muted++ }, + onArchive = { archived++ }, + stateKey = "chat", + revealGroup = rememberSwipeRevealGroup(), + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + composeRule.waitForIdle() + } + + private fun rowLeft() = composeRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().left + + @Test + fun `archive is outermost and a full swipe from the revealed row archives`() { + show() + + composeRule.onNodeWithTag(ROW).performTouchInput { + swipeLeft(startX = right - 1f, endX = right - 120.dp.toPx(), durationMillis = 1_500) + } + composeRule.waitForIdle() + assertTrue(rowLeft() < 0.dp, "the short drag should leave the row revealed") + assertEquals(0, archived) + assertEquals(0, muted) + + val mute = composeRule.onNodeWithContentDescription(MUTE).getUnclippedBoundsInRoot() + val archive = composeRule.onNodeWithContentDescription(ARCHIVE).getUnclippedBoundsInRoot() + assertTrue(archive.left > mute.left, "archive should sit outside mute, at the trailing edge") + + composeRule.onNodeWithTag(ROW).performTouchInput { + swipeLeft(startX = right - 1f, endX = left, durationMillis = 120) + } + composeRule.waitForIdle() + + assertEquals(1, archived) + assertEquals(0, muted) + } + + private companion object { + const val ROW = "row" + const val MUTE = "Mute notifications" + const val ARCHIVE = "Archive chat" + } +} diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt new file mode 100644 index 0000000000..62dbc11874 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt @@ -0,0 +1,346 @@ +package com.flipcash.app.tipping.internal + +import androidx.test.core.app.ApplicationProvider +import com.flipcash.analytics.ChatReadSource +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatListProjection +import com.flipcash.shared.chat.ChatMarkedUnreadStore +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadActions +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Rate +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.time.Instant +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The chat list's row actions and select mode: entering and leaving it, what the bar offers, and + * the spec cases that live above the shared rules (Read All's Cancel, bulk Mute, bulk Archive with + * Undo, and Requests chats taking no read action). The rules themselves are tested in shared/chat. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChatListSelectionTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + @Before + fun setUp() = BottomBarManager.clear() + + @After + fun tearDown() = BottomBarManager.clear() + + private fun id(n: Int) = ChatId(ByteArray(16) { n.toByte() }.toList()) + + private fun summary(n: Int, type: ChatType = ChatType.TIP_DM, unread: Int = 0, marked: Boolean = false) = + ChatSummary( + metadata = ChatMetadata( + chatId = id(n), + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000L + n), + title = "chat $n", + latestEventSequence = 0, + ), + unreadCount = unread, + markedUnread = marked, + ) + + // --- select mode, through the reducer --- + + private fun reduce(state: ChatsViewModel.State, event: ChatsViewModel.Event) = + ChatsViewModel.updateStateForEvent(event)(state) + + private val unreadA = ConversationReference(chatId = id(1), unreadCount = 3) + private val readC = ConversationReference(chatId = id(3)) + private val readE = ConversationReference(chatId = id(5)) + + private val loaded = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated( + Loadable.Loaded(listOf(unreadA, readC, readE)), + ChatListProjection.Empty.copy(main = listOf(unreadA, readC, readE).map { it.chatId.toString() }), + ), + ) + + private fun ChatsViewModel.State.select(vararg ids: ChatId): ChatsViewModel.State = + ids.fold(reduce(this, ChatsViewModel.Event.SelectionStarted(ids.first()))) { state, chatId -> + if (chatId == ids.first()) state else reduce(state, ChatsViewModel.Event.SelectionToggled(chatId)) + } + + @Test + fun `a long press enters select mode with that chat selected`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(readC.chatId)) + assertTrue(selecting.isSelecting) + assertEquals(listOf(readC), selecting.selectedChats) + assertEquals(1, selecting.selectionActions.count) + } + + @Test + fun `Select Chats enters select mode with nothing selected`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(null)) + assertTrue(selecting.isSelecting) + assertEquals(0, selecting.selectionActions.count) + } + + @Test + fun `deselecting the last row leaves select mode`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(readC.chatId)) + val left = reduce(selecting, ChatsViewModel.Event.SelectionToggled(readC.chatId)) + assertFalse(left.isSelecting) + } + + // Spec case 12. + @Test + fun `an unread chat in the selection makes the toggle Mark as Read`() { + assertEquals(ChatReadToggle.MarkRead, loaded.select(unreadA.chatId, readC.chatId).selectionActions.read) + } + + // Spec case 13. + @Test + fun `an all-read selection makes the toggle Mark as Unread`() { + assertEquals(ChatReadToggle.MarkUnread, loaded.select(readC.chatId, readE.chatId).selectionActions.read) + } + + @Test + fun `Read All is disabled when nothing in the main list is unread`() { + val allRead = reduce( + loaded, + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(readC, readE)), ChatListProjection.Empty), + ) + assertFalse(allRead.canReadAll) + assertTrue(loaded.canReadAll) + val marked = readC.copy(markedUnread = true) + assertTrue( + reduce(loaded, ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(marked)), ChatListProjection.Empty)) + .canReadAll + ) + } + + // --- the view model, for the actions that leave select mode --- + + private val rowActions = mockk(relaxed = true) + + private fun TestScope.viewModel(main: List): HiltChatsViewModel { + val feeds = MutableStateFlow(ChatFeeds(main = main, archived = emptyList())) + val chatCoordinator = mockk(relaxed = true).also { + every { it.feedWithArchived(*anyVararg()) } returns feeds + every { it.currentFeedWithArchived(*anyVararg()) } returns feeds.value + every { it.observeSenderProfiles() } returns flowOf(emptyMap()) + } + every { rowActions.summaries(any()) } answers { + val ids = firstArg>() + main.filter { it.metadata.chatId in ids } + } + return HiltChatsViewModel( + exchange = mockk(relaxed = true).also { + every { it.observePreferredRate() } returns flowOf(Rate.oneToOne) + every { it.observeRates() } returns flowOf(emptyMap()) + }, + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + tokenCoordinator = mockk(relaxed = true).also { + every { it.tokens } returns flowOf(emptyList()) + every { it.cachedTokens() } returns emptyList() + }, + resources = FakeResourceHelper(R::class.java), + dispatchers = TestDispatchers(testScheduler), + archiveStore = ChatArchiveStore.None, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = rowActions, + ) + } + + // Spec case 11. + @Test + fun `cancelling Read All changes nothing`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(listOf(summary(1, unread = 2))) + vm.requestReadAll() + + val prompt = BottomBarManager.messages.value.single() + assertTrue(prompt.showCancel) + assertEquals("prompt_title_readAllChats", prompt.title) + // Cancel is the prompt's own button and closes it; nothing else runs. + BottomBarManager.clear() + advanceUntilIdle() + coVerify(exactly = 0) { rowActions.readAll() } + + vm.requestReadAll() + BottomBarManager.messages.value.single().actions.single().onClick() + advanceUntilIdle() + coVerify(exactly = 1) { rowActions.readAll() } + } + + // Spec case 14. + @Test + fun `bulk Mute opens one sheet for the whole selection`() = runTest(mainCoroutineRule.dispatcher) { + val muted = summary(1, type = ChatType.GROUP) + val audible = summary(2) + val vm = viewModel(listOf(muted, audible)) + advanceUntilIdle() + vm.startSelection(muted.metadata.chatId) + vm.toggleSelection(audible.metadata.chatId) + assertEquals(ChatMuteToggle.Mute, vm.stateFlow.value.selectionActions.mute) + + val sheet = vm.muteSelection() + + assertEquals(listOf(id(1), id(2)) to listOf(ChatType.GROUP, ChatType.TIP_DM), sheet) + assertFalse(vm.stateFlow.value.isSelecting) + } + + // Spec case 16. + @Test + fun `bulk Archive archives every selected chat and Undo restores them all`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(listOf(summary(1), summary(2))) + advanceUntilIdle() + vm.startSelection(id(1)) + vm.toggleSelection(id(2)) + + val archived = vm.archiveSelection() + advanceUntilIdle() + + assertEquals(setOf(id(1), id(2)), archived.toSet()) + coVerify(exactly = 1) { rowActions.archive(match { it.toSet() == setOf(id(1), id(2)) }) } + assertFalse(vm.stateFlow.value.isSelecting) + + vm.unarchive(archived) + advanceUntilIdle() + coVerify(exactly = 1) { rowActions.unarchive(match { it.toSet() == setOf(id(1), id(2)) }) } + } + + @Test + fun `the archive toast reads Chat archived for one and counts more`() { + val resources = ApplicationProvider.getApplicationContext().resources + assertEquals("Chat archived", resources.getQuantityString(R.plurals.message_chatsArchived, 1, 1)) + assertEquals("2 chats archived", resources.getQuantityString(R.plurals.message_chatsArchived, 2, 2)) + } + + // --- ChatRowActions: what each path sends --- + + private val marks = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + + private fun actions(main: List): Pair { + val coordinator = mockk(relaxed = true).also { + every { it.currentFeedWithArchived(*anyVararg()) } returns ChatFeeds(main, emptyList()) + } + return ChatRowActions( + chatCoordinator = coordinator, + readActions = ChatReadActions(coordinator, marks), + archiveStore = ChatArchiveStore.None, + analytics = analytics, + ) to coordinator + } + + // Spec case 1: marks locally, sends nothing, reports the swipe. + @Test + fun `a swipe on a read chat marks it unread without an RPC`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(3))) + + actions.toggleRead(id(3), ChatReadSource.SWIPE) + + coVerify(exactly = 1) { marks.mark(id(3)) } + coVerify(exactly = 0) { coordinator.markAsRead(any()) } + assertEquals( + listOf(ChatEvents.markedUnread(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SWIPE)), + analytics.events, + ) + } + + // Spec case 3: the swipe offers Mark as Read on an unread chat and advances READ. + @Test + fun `a swipe on an unread chat advances READ`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(1, unread = 3))) + + actions.toggleRead(id(1), ChatReadSource.SWIPE) + + coVerify(exactly = 1) { coordinator.markAsRead(id(1)) } + coVerify(exactly = 0) { marks.mark(any()) } + assertEquals( + listOf(ChatEvents.markedRead(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SWIPE)), + analytics.events, + ) + } + + @Test + fun `select mode reports one event per selected chat`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, _) = actions(listOf(summary(3), summary(5, type = ChatType.GROUP))) + + actions.toggleRead(listOf(id(3), id(5))) + + assertEquals( + listOf( + ChatEvents.markedUnread(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SELECTION), + ChatEvents.markedUnread(chatType = ChatType.GROUP.analytics, source = ChatReadSource.SELECTION), + ), + analytics.events, + ) + } + + // Spec case 9's count reaches the event. + @Test + fun `Read All reports how many chats it changed`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, _) = actions(listOf(summary(1, unread = 2), summary(2, marked = true), summary(3))) + + actions.readAll() + + assertEquals(listOf(ChatEvents.readAll(count = 2)), analytics.events) + } + + // Spec case 18: a chat still in Requests is in neither list, so no read action reaches it. + @Test + fun `a chat still in Requests takes no read action`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(1))) + val requested = id(9) + + actions.toggleRead(requested, ChatReadSource.SWIPE) + actions.toggleRead(listOf(requested)) + + coVerify(exactly = 0) { marks.mark(any()) } + coVerify(exactly = 0) { coordinator.markAsRead(any()) } + assertTrue(analytics.events.isEmpty()) + assertNull(actions.summaries(listOf(requested)).firstOrNull()) + } +} diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt new file mode 100644 index 0000000000..f34ac61b8d --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.tipping.internal + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.tipping.internal.components.AVATAR_TAG +import com.flipcash.app.tipping.internal.components.SELECT_CIRCLE_TAG +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Select mode's enter and exit: the row content slides right by the circle's slot while the + * circle stays where it ends up and fades in, both driven by the one reveal progress. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class SelectCircleRevealTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun circleStaysPutAndFadesWhileContentSlides() { + val progress = mutableFloatStateOf(0f) + var background = Color.Unspecified + var indicator = Color.Unspecified + composeRule.setContent { + FlipcashPreview(showBackground = true) { + background = CodeTheme.colors.background + indicator = CodeTheme.colors.indicator + TipChatRow( + chat = ConversationReference( + chatId = ChatId(byteArrayOf(1)), + displayName = "Grace Hopper", + handle = null, + lastMessagePreview = "Hello", + lastActivity = Instant.fromEpochMilliseconds(1_700_000_000_000), + ), + // Checked, so the circle is a filled disc whose colour shows its alpha. + circleReveal = { progress.floatValue }, + circleChecked = true, + onClick = {}, + ) + } + } + + val density = composeRule.density + val tolerance = with(density) { 1.dp.toPx() } + + fun circleX() = composeRule.onNodeWithTag(SELECT_CIRCLE_TAG, useUnmergedTree = true) + .fetchSemanticsNode().positionInRoot.x + fun avatarX() = composeRule.onNodeWithTag(AVATAR_TAG, useUnmergedTree = true) + .fetchSemanticsNode().positionInRoot.x + + composeRule.waitForIdle() + val restingCircleX = circleX() + val restingAvatarX = avatarX() + + // The circle plus its gap to the avatar: the distance the content slides at full progress. + progress.floatValue = 1f + composeRule.waitForIdle() + val slot = avatarX() - restingAvatarX + assertTrue(slot > with(density) { 20.dp.toPx() }, "content slid only $slot px") + + for (p in listOf(0.25f, 0.5f, 0.75f, 1f)) { + progress.floatValue = p + composeRule.waitForIdle() + + assertEquals(restingCircleX, circleX(), tolerance, "circle moved at progress $p") + assertEquals(slot * p, avatarX() - restingAvatarX, tolerance, "content offset at progress $p") + + // 3dp into the disc, level with its centre: inside the circle and left of the content + // edge at every progress sampled here. + val node = composeRule.onNodeWithTag(SELECT_CIRCLE_TAG, useUnmergedTree = true).fetchSemanticsNode() + val x = (circleX() + with(density) { 3.dp.toPx() }).toInt() + val y = (node.positionInRoot.y + node.size.height / 2f).toInt() + val pixel = Color(composeRule.onRoot().captureToImage().asAndroidBitmap().getPixel(x, y)) + // Alpha composites each sRGB channel linearly over the row background. + fun mix(a: Float, b: Float) = a + (b - a) * p + val expected = Color( + mix(background.red, indicator.red), + mix(background.green, indicator.green), + mix(background.blue, indicator.blue), + ) + assertTrue( + abs(pixel.red - expected.red) < 0.03f && abs(pixel.green - expected.green) < 0.03f && + abs(pixel.blue - expected.blue) < 0.03f, + "circle colour at progress $p: ${Integer.toHexString(pixel.toArgb())}, " + + "expected ${Integer.toHexString(expected.toArgb())}", + ) + } + } +} 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 index d593115685..bc80b4b341 100644 --- 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 @@ -1,6 +1,9 @@ -// kmp-audit: hiltViewModel is Android-only; the screen content is ArchivedChatsContent in commonMain. +// kmp-audit: hiltViewModel and BackHandler are Android-only; the screen content is ArchivedChatsContent in commonMain. package com.flipcash.app.tipping +import com.flipcash.app.tipping.internal.components.ChatSelectionTopBar +import com.flipcash.app.tipping.internal.components.SelectionArchiveAction +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,17 +29,39 @@ fun ArchivedChatsScreen() { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current + // Close, system back, or deselecting the last row leaves select mode. + BackHandler(enabled = state.isSelecting) { viewModel.endSelection() } + ArchivedChatsContent( chats = state.chats.dataOrNull.orEmpty(), isLoaded = state.chats.isLoaded(), onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, onUnarchive = viewModel::unarchive, + selection = state.selection, + onStartSelection = viewModel::startSelection, + onToggleSelection = viewModel::toggleSelection, + onToggleRead = viewModel::toggleRead, topBar = { - AppBarWithTitle( - title = stringResource(Res.string.title_archived), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { navigator.pop() }, - ) + ChatSelectionTopBar( + selectionActions = if (state.isSelecting) state.selectionActions else null, + archiveAction = SelectionArchiveAction.Unarchive, + onClose = viewModel::endSelection, + onRead = viewModel::readSelection, + onMute = { + viewModel.muteSelection()?.let { (ids, types) -> + navigator.push(AppRoute.Messaging.MuteChats(ids, types)) + } + }, + onUnmute = viewModel::unmuteSelection, + onArchive = viewModel::unarchiveSelection, + label = "archived list bar", + ) { + 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 index 41095c5c2e..7edc69854e 100644 --- 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 @@ -1,6 +1,12 @@ -// kmp-audit: hiltViewModel is Android-only; the screen content is ChatsScreenContent in commonMain. +// kmp-audit: hiltViewModel and BackHandler are Android-only; the screen content is ChatsScreenContent in commonMain. package com.flipcash.app.tipping +import androidx.activity.compose.BackHandler +import com.flipcash.app.tipping.internal.components.ChatListMoreButton +import com.flipcash.app.tipping.internal.components.ChatSelectionTopBar +import com.flipcash.app.tipping.internal.components.SelectionArchiveAction +import com.flipcash.services.models.chat.ChatId +import org.jetbrains.compose.resources.getPluralString import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Archive @@ -15,7 +21,7 @@ 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.message_chatsArchived import com.flipcash.core.resources.title_tabChat import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults @@ -38,9 +44,25 @@ fun ChatsScreen() { // 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) + // Archive with one Undo for everything it took: the swipe passes one chat, select mode several. + val archiveWithUndo: (List) -> Unit = { ids -> + if (toasts != null && ids.isNotEmpty()) { + scope.launch { + val result = toasts.show( + message = getPluralString(Res.plurals.message_chatsArchived, ids.size, ids.size), + icon = Icons.Outlined.Archive, + actionLabel = undoLabel, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(ids) + } + } + } + + // Close, system back, or deselecting the last row leaves select mode. + BackHandler(enabled = state.isSelecting) { viewModel.endSelection() } + ChatsScreenContent( state = state, onSelectFilter = viewModel::selectFilter, @@ -53,30 +75,48 @@ fun ChatsScreen() { 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) - } - } + archiveWithUndo(listOf(chat.chatId)) }, + onToggleRead = { chat -> viewModel.toggleRead(chat.chatId) }, + onStartSelection = viewModel::startSelection, + onToggleSelection = viewModel::toggleSelection, 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) } + // Select mode takes the bar over, as a message selection does in a chat. + ChatSelectionTopBar( + selectionActions = if (state.isSelecting) state.selectionActions else null, + archiveAction = SelectionArchiveAction.Archive, + onClose = viewModel::endSelection, + onRead = viewModel::readSelection, + onMute = { + viewModel.muteSelection()?.let { (ids, types) -> + navigator.push(AppRoute.Messaging.MuteChats(ids, types)) + } }, - ) + onUnmute = viewModel::unmuteSelection, + onArchive = { archiveWithUndo(viewModel.archiveSelection()) }, + label = "chat list bar", + ) { + 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 13201:108 — opposite Add and drawn the same way. + leadingContent = { + ChatListMoreButton( + canReadAll = state.canReadAll, + onSelectChats = { viewModel.startSelection() }, + onReadAll = viewModel::requestReadAll, + ) + }, + // 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 index c118e2e065..d8b55110de 100644 --- 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 @@ -18,6 +18,7 @@ internal class HiltArchivedChatsViewModel @Inject constructor( exchange: Exchange, chatCoordinator: ChatCoordinator, archiveStore: ChatArchiveStore, + rowActions: ChatRowActions, userManager: UserManager, tokenCoordinator: TokenCoordinator, resources: ResourceHelper, @@ -28,6 +29,7 @@ internal class HiltArchivedChatsViewModel @Inject constructor( exchange = exchange, chatCoordinator = chatCoordinator, archiveStore = archiveStore, + rowActions = rowActions, userManager = userManager, tokenCoordinator = tokenCoordinator, resources = resources, 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 index c053196da3..0d32a0146b 100644 --- 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 @@ -1,5 +1,11 @@ package com.flipcash.app.tipping.internal +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.prompt_description_readAllChats +import com.flipcash.core.resources.prompt_title_readAllChats +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -13,16 +19,17 @@ import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -/** Hilt entry point for the shared [ChatsViewModel]. */ +/** Hilt entry point for the shared [ChatsViewModel], plus Read All's Android-only confirm prompt. */ @HiltViewModel internal class HiltChatsViewModel @Inject constructor( exchange: Exchange, chatCoordinator: ChatCoordinator, userManager: UserManager, tokenCoordinator: TokenCoordinator, - resources: ResourceHelper, + private val resources: ResourceHelper, dispatchers: DispatcherProvider, archiveStore: ChatArchiveStore, + rowActions: ChatRowActions, lobbyRequests: LobbyRequests, hostLobbies: HostLobbies, paymentRequests: PaymentRequests, @@ -34,7 +41,23 @@ internal class HiltChatsViewModel @Inject constructor( resources = resources, dispatchers = dispatchers, archiveStore = archiveStore, + rowActions = rowActions, lobbyRequests = lobbyRequests, hostLobbies = hostLobbies, paymentRequests = paymentRequests, -) +) { + + /** + * Read All asks first: it moves the READ pointer, which everyone in those chats sees. Cancel + * leaves everything as it was; Mark as Read runs [readAll]. Here rather than in [ChatsViewModel] + * because [BottomBarManager] is Android-only. + */ + fun requestReadAll() { + BottomBarManager.showMessage( + title = resources.getString(Res.string.prompt_title_readAllChats), + message = resources.getString(Res.string.prompt_description_readAllChats), + actions = listOf(BottomBarAction(text = resources.getString(Res.string.action_markAsRead)) { readAll() }), + showCancel = true, + ) + } +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt new file mode 100644 index 0000000000..136828f2fa --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt @@ -0,0 +1,266 @@ +// kmp-audit: AppBarWithTitle and CircularIconButton are Android-only; the rows and the read swipe are in commonMain. +package com.flipcash.app.tipping.internal.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Unarchive +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.tipping.internal.ChatSelectionActions +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_archive +import com.flipcash.core.resources.action_closeChatSelection +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.action_markAsUnread +import com.flipcash.core.resources.action_mute +import com.flipcash.core.resources.action_readAll +import com.flipcash.core.resources.action_selectChats +import com.flipcash.core.resources.action_unarchive +import com.flipcash.core.resources.action_unmute +import com.flipcash.core.resources.content_description_moreChatActions +import com.flipcash.core.resources.title_chatsSelected +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadToggle +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton +import com.getcode.util.resources.Res as UiRes +import com.getcode.util.resources.ic_mark_chat_read +import com.getcode.util.resources.ic_mark_chat_unread +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** The archive slot of the select-mode bar: Archive on the Chats list, Unarchive on Archived. */ +internal enum class SelectionArchiveAction { Archive, Unarchive } + +/** + * The list's title bar, or select mode's [ChatSelectionBar] in its place. Keyed on select mode + * only, so the bar fading out keeps the selection it showed rather than the emptied one that ended + * select mode. + */ +@Composable +internal fun ChatSelectionTopBar( + selectionActions: ChatSelectionActions?, + archiveAction: SelectionArchiveAction, + onClose: () -> Unit, + onRead: () -> Unit, + onMute: () -> Unit, + onUnmute: () -> Unit, + onArchive: () -> Unit, + label: String, + titleBar: @Composable () -> Unit, +) { + AnimatedContent( + targetState = selectionActions, + contentKey = { it != null }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = label, + ) { actions -> + if (actions != null) { + ChatSelectionBar( + actions = actions, + archiveAction = archiveAction, + onClose = onClose, + onRead = onRead, + onMute = onMute, + onUnmute = onUnmute, + onArchive = onArchive, + ) + } else { + titleBar() + } + } +} + +/** + * Select mode's contextual bar, in place of the title bar: close, the selected count, then the read + * toggle, mute and archive as icon buttons. Built like the in-chat message selection bar (leading + * control, title, icon actions in the end slot). The actions sit disabled with nothing selected, + * which is how Select Chats opens. + */ +@Composable +internal fun ChatSelectionBar( + actions: ChatSelectionActions, + archiveAction: SelectionArchiveAction, + onClose: () -> Unit, + onRead: () -> Unit, + onMute: () -> Unit, + onUnmute: () -> Unit, + onArchive: () -> Unit, + modifier: Modifier = Modifier, +) { + val enabled = actions.count > 0 + AppBarWithTitle( + modifier = modifier, + leftIcon = { + SelectionBarButton( + icon = Icons.Outlined.Close, + label = stringResource(Res.string.action_closeChatSelection), + testTag = "action_close_chat_selection", + onClick = onClose, + ) + }, + title = { + Text( + modifier = Modifier.testTag("chat_selection_count"), + text = stringResource(Res.string.title_chatsSelected, actions.count), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + rightContents = { + when (actions.read) { + ChatReadToggle.MarkRead -> SelectionBarButton( + painter = UiRes.drawable.ic_mark_chat_read, + label = stringResource(Res.string.action_markAsRead), + testTag = "action_mark_selection_read", + enabled = enabled, + onClick = onRead, + ) + ChatReadToggle.MarkUnread -> SelectionBarButton( + painter = UiRes.drawable.ic_mark_chat_unread, + label = stringResource(Res.string.action_markAsUnread), + testTag = "action_mark_selection_unread", + enabled = enabled, + onClick = onRead, + ) + } + when (actions.mute) { + ChatMuteToggle.Mute -> SelectionBarButton( + icon = Icons.Outlined.NotificationsOff, + label = stringResource(Res.string.action_mute), + testTag = "action_mute_selection", + enabled = enabled, + onClick = onMute, + ) + ChatMuteToggle.Unmute -> SelectionBarButton( + icon = Icons.Outlined.Notifications, + label = stringResource(Res.string.action_unmute), + testTag = "action_unmute_selection", + enabled = enabled, + onClick = onUnmute, + ) + } + when (archiveAction) { + SelectionArchiveAction.Archive -> SelectionBarButton( + icon = Icons.Outlined.Archive, + label = stringResource(Res.string.action_archive), + testTag = "action_archive_selection", + enabled = enabled, + onClick = onArchive, + ) + SelectionArchiveAction.Unarchive -> SelectionBarButton( + icon = Icons.Outlined.Unarchive, + label = stringResource(Res.string.action_unarchive), + testTag = "action_unarchive_selection", + enabled = enabled, + onClick = onArchive, + ) + } + }, + ) +} + +@Composable +private fun SelectionBarButton( + label: String, + testTag: String, + onClick: () -> Unit, + icon: ImageVector? = null, + painter: DrawableResource? = null, + enabled: Boolean = true, +) { + CircularIconButton(onClick = onClick, enabled = enabled, testTag = testTag) { size -> + val tint = if (enabled) Color.White else CodeTheme.colors.textSecondary + val iconModifier = Modifier.requiredSize(size) + if (icon != null) { + Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = iconModifier) + } else if (painter != null) { + Icon(painter = painterResource(painter), contentDescription = label, tint = tint, modifier = iconModifier) + } + } +} + +/** + * The "…" button on the Chats title bar's leading side, opposite `+` and drawn the same way, with + * its menu: Select Chats, and Read All (disabled when nothing in the main list is unread). The menu + * surface is the in-chat overflow's. + */ +@Composable +internal fun ChatListMoreButton( + canReadAll: Boolean, + onSelectChats: () -> Unit, + onReadAll: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + CircularIconButton(onClick = { expanded = true }, testTag = "action_chat_list_more") { size -> + Icon( + imageVector = Icons.Outlined.MoreHoriz, + contentDescription = stringResource(Res.string.content_description_moreChatActions), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + DropdownMenu( + expanded = expanded, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + onDismissRequest = { expanded = false }, + ) { + // The app's colors, as ChatTopBar's menu sets them; M3's defaults are dark on this surface. + val itemColors = MenuDefaults.itemColors( + textColor = CodeTheme.colors.textMain, + disabledTextColor = CodeTheme.colors.textSecondary, + ) + DropdownMenuItem( + modifier = Modifier.testTag("action_select_chats"), + colors = itemColors, + text = { Text(stringResource(Res.string.action_selectChats), style = CodeTheme.typography.textMedium) }, + onClick = { + expanded = false + onSelectChats() + }, + ) + DropdownMenuItem( + modifier = Modifier.testTag("action_read_all"), + colors = itemColors, + text = { Text(stringResource(Res.string.action_readAll), style = CodeTheme.typography.textMedium) }, + enabled = canReadAll, + onClick = { + expanded = false + onReadAll() + }, + ) + } + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt index ada9ae2955..1441ec9b0c 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -1,9 +1,14 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.action_unarchive +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import com.flipcash.app.tipping.internal.components.markReadSwipeAction 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 com.flipcash.app.tipping.internal.components.rememberSelectCircles import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -43,15 +48,22 @@ internal fun ArchivedChatsContent( onOpen: (ChatId) -> Unit, onUnarchive: (ChatId) -> Unit, topBar: @Composable () -> Unit, + // Select mode: the chats picked so far, or null outside it, and what the bar can do to them. + selection: Set? = null, + onStartSelection: (ChatId) -> Unit = {}, + onToggleSelection: (ChatId) -> Unit = {}, + onToggleRead: (ChatId) -> Unit = {}, ) { // One row open at a time, as on the Chats list. val revealGroup = rememberSwipeRevealGroup() + val haptics = LocalHapticFeedback.current CodeScaffold( 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. + val circles = rememberSelectCircles(selection) LazyColumn( modifier = Modifier.fillMaxSize().testTag("archived_list"), contentPadding = PaddingValues(top = padding.calculateTopPadding()), @@ -80,13 +92,29 @@ internal fun ArchivedChatsContent( itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> UnarchiveSwipeRow( modifier = Modifier.animateItem(), + isUnread = chat.isUnread, + // Swipes are off while selecting. + swipeEnabled = selection == null, + onToggleRead = { onToggleRead(chat.chatId) }, onUnarchive = { onUnarchive(chat.chatId) }, stateKey = chat.chatId, revealGroup = revealGroup, ) { // The row draws its divider below itself, so none sits above the first row. - TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { - onOpen(chat.chatId) + TipChatRow( + chat = chat, + showDivider = index < chats.lastIndex, + selected = selection?.let { chat.chatId in it }, + circleReveal = circles.reveal, + circleChecked = chat.chatId in circles.checked, + onLongClick = { + if (selection == null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onStartSelection(chat.chatId) + } + }, + ) { + if (selection != null) onToggleSelection(chat.chatId) else onOpen(chat.chatId) } } } @@ -96,6 +124,9 @@ internal fun ArchivedChatsContent( @Composable private fun UnarchiveSwipeRow( + isUnread: Boolean, + swipeEnabled: Boolean, + onToggleRead: () -> Unit, onUnarchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, @@ -103,11 +134,19 @@ private fun UnarchiveSwipeRow( content: @Composable () -> Unit, ) { val label = stringResource(Res.string.content_description_unarchiveChat) + val unarchiveTitle = stringResource(Res.string.action_unarchive) + val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) + if (!swipeEnabled) { + Box(modifier) { content() } + return + } SwipeActionRow( + leadingActions = listOf(readAction), actions = listOf( SwipeAction( background = CodeTheme.colors.surfaceVariant, onTriggered = onUnarchive, + label = unarchiveTitle, ) { Icon( imageVector = Icons.Outlined.Unarchive, @@ -119,7 +158,10 @@ private fun UnarchiveSwipeRow( ), // A swipe is out of reach with a screen reader, so the row offers the action there. modifier = modifier.semantics { - customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) + customActions = listOf( + CustomAccessibilityAction(readLabel) { onToggleRead(); true }, + CustomAccessibilityAction(label) { onUnarchive(); true }, + ) }, stateKey = stateKey, revealGroup = revealGroup, diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index 4ad16825b2..ec728cee9d 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -1,5 +1,12 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.action_unmute +import com.flipcash.core.resources.action_mute +import com.flipcash.core.resources.action_archive +import com.flipcash.app.tipping.internal.components.markReadSwipeAction +import com.flipcash.app.tipping.internal.components.SelectCircles +import com.flipcash.app.tipping.internal.components.rememberSelectCircles +import com.flipcash.services.models.chat.ChatId import com.flipcash.core.resources.Res import com.flipcash.core.resources.action_undo import com.flipcash.core.resources.content_description_archiveChat @@ -122,6 +129,11 @@ fun ChatsScreenContent( onMute: (ConversationReference) -> Unit, onArchive: (ConversationReference) -> Unit, topBar: @Composable () -> Unit, + // The leading swipe: Mark as Read or Mark as Unread, whichever the row offers. + onToggleRead: (ConversationReference) -> Unit = {}, + // Select mode: a long press (with that chat) or Select Chats (with none) starts it. + onStartSelection: (ChatId?) -> Unit = {}, + onToggleSelection: (ChatId) -> Unit = {}, ) { val chats = state.chats // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips @@ -172,6 +184,7 @@ fun ChatsScreenContent( barPlacement = ScaffoldBarPlacement.Overlay, topBar = topBar, ) { barPadding -> + val circles = rememberSelectCircles(state.selection) Box(modifier = Modifier.fillMaxSize()) { LazyColumn( modifier = Modifier @@ -270,13 +283,24 @@ fun ChatsScreenContent( } else -> tipChatItems( chats = visible, - onClick = onOpenChat, + selection = state.selection, + onClick = { chat -> + if (state.isSelecting) onToggleSelection(chat.chatId) else onOpenChat(chat) + }, + onLongClick = { chat -> + if (!state.isSelecting) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onStartSelection(chat.chatId) + } + }, + onToggleRead = onToggleRead, // 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 = onMute, onArchive = onArchive, revealGroup = revealGroup, parkFiller = parkFiller, + circles = circles, ) } @@ -338,11 +362,15 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { private fun LazyListScope.tipChatItems( chats: List, + selection: Set?, onClick: (ConversationReference) -> Unit, + onLongClick: (ConversationReference) -> Unit, + onToggleRead: (ConversationReference) -> Unit, onMute: (ConversationReference) -> Unit, onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, parkFiller: ParkFiller, + circles: SelectCircles, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. // animateItem fades a row out where it stands when it is archived (or filtered away), even @@ -351,6 +379,10 @@ private fun LazyListScope.tipChatItems( ChatSwipeRow( modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), isMuted = rememberIsMuted(chat.viewerState), + isUnread = chat.isUnread, + // Swipes are off while selecting. + swipeEnabled = selection == null, + onToggleRead = { onToggleRead(chat) }, onMute = { onMute(chat) }, onArchive = { onArchive(chat) }, stateKey = chat.chatId, @@ -359,6 +391,10 @@ private fun LazyListScope.tipChatItems( TipChatRow( chat = chat, showDivider = index < chats.lastIndex, + selected = selection?.let { chat.chatId in it }, + circleReveal = circles.reveal, + circleChecked = chat.chatId in circles.checked, + onLongClick = { onLongClick(chat) }, ) { onClick(chat) } @@ -367,9 +403,10 @@ private fun LazyListScope.tipChatItems( } /** - * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action - * outermost and fires it on a full swipe, so a full swipe still opens the mute sheet as before, and - * archive is a tap on the revealed action. + * One leading swipe action, Mark as Read or Mark as Unread (a full swipe fires it), and two + * trailing ones: mute inside, archive outside. [SwipeActionRow] draws the last action + * outermost and fires it on a full swipe, so a full swipe archives and mute is a tap on the + * revealed action. * * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never @@ -380,8 +417,11 @@ private fun LazyListScope.tipChatItems( * closed row. */ @Composable -private fun ChatSwipeRow( +internal fun ChatSwipeRow( isMuted: Boolean, + isUnread: Boolean, + swipeEnabled: Boolean, + onToggleRead: () -> Unit, onMute: () -> Unit, onArchive: () -> Unit, stateKey: Any, @@ -394,25 +434,24 @@ private fun ChatSwipeRow( else Res.string.content_description_muteChat ) val archiveLabel = stringResource(Res.string.content_description_archiveChat) + // The titles under the circles use the select-mode bar's wording. + val muteTitle = stringResource(if (isMuted) Res.string.action_unmute else Res.string.action_mute) + val archiveTitle = stringResource(Res.string.action_archive) + val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) + if (!swipeEnabled) { + Box(modifier) { content() } + return + } SwipeActionRow( + leadingActions = listOf(readAction), actions = listOf( - SwipeAction( - background = CodeTheme.colors.surfaceVariant, - onTriggered = onArchive, - ) { - Icon( - imageVector = Icons.Outlined.Archive, - contentDescription = archiveLabel, - tint = CodeTheme.colors.textMain, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), - ) - }, SwipeAction( // The profile's unverified badge treatment rather than the delete red: nothing is // lost by it. background = CodeTheme.colors.warning.copy(alpha = 0.15f), onTriggered = onMute, resetOnDismiss = true, + label = muteTitle, ) { Icon( imageVector = if (isMuted) { @@ -425,10 +464,23 @@ private fun ChatSwipeRow( modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) }, + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onArchive, + label = archiveTitle, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = archiveLabel, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, ), // A swipe is out of reach with a screen reader, so the row offers the same actions there. modifier = modifier.semantics { customActions = listOf( + CustomAccessibilityAction(readLabel) { onToggleRead(); true }, CustomAccessibilityAction(muteLabel) { onMute(); true }, CustomAccessibilityAction(archiveLabel) { onArchive(); true }, ) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index a3bc0568b5..223687b04d 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -6,6 +6,9 @@ import com.flipcash.app.core.data.Loadable import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.analytics.ChatReadSource +import com.flipcash.shared.chat.toggledSelection import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator @@ -34,6 +37,7 @@ internal open class ArchivedChatsViewModel @Inject constructor( dispatchers: DispatcherProvider, paymentRequests: PaymentRequests, hostLobbies: HostLobbies, + private val rowActions: ChatRowActions, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -41,16 +45,60 @@ internal open class ArchivedChatsViewModel @Inject constructor( data class State( val chats: Loadable> = Loadable.Loading(), - ) + // Select mode, as on the Chats list: the chats picked so far, or null outside it. + val selection: Set? = null, + ) { + val isSelecting: Boolean get() = selection != null + + val selectedChats: List + get() { + val ids = selection ?: return emptyList() + return chats.dataOrNull.orEmpty().filter { it.chatId in ids } + } + + val selectionActions: ChatSelectionActions get() = ChatSelectionActions.of(selectedChats) + } sealed interface Event { data class ChatsUpdated(val chats: Loadable>) : Event + data class SelectionStarted(val chatId: ChatId) : Event + data class SelectionToggled(val chatId: ChatId) : Event + data object SelectionEnded : Event } fun unarchive(chatId: ChatId) { viewModelScope.launch { archiveStore.unarchive(chatId) } } + fun toggleRead(chatId: ChatId) { + viewModelScope.launch { rowActions.toggleRead(chatId, ChatReadSource.SWIPE) } + } + + fun startSelection(chatId: ChatId) = dispatchEvent(Event.SelectionStarted(chatId)) + + fun toggleSelection(chatId: ChatId) = dispatchEvent(Event.SelectionToggled(chatId)) + + fun endSelection() = dispatchEvent(Event.SelectionEnded) + + fun readSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.toggleRead(ids) } } + + fun unmuteSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unmute(ids) } } + + fun muteSelection(): Pair, List>? { + var target: Pair, List>? = null + actOnSelection { ids -> target = rowActions.chatTypes(ids) } + return target + } + + /** Select mode's Unarchive: the selected chats go back to the main list. */ + fun unarchiveSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unarchive(ids) } } + + private inline fun actOnSelection(action: (List) -> Unit) { + val ids = stateFlow.value.selectedChats.map { it.chatId } + if (ids.isNotEmpty()) action(ids) + endSelection() + } + init { combine( chatCoordinator.archivedChatListFeed(), @@ -73,9 +121,14 @@ internal open class ArchivedChatsViewModel @Inject constructor( } internal companion object { - private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + is Event.SelectionStarted -> { state -> state.copy(selection = setOf(event.chatId)) } + is Event.SelectionToggled -> { state -> + state.copy(selection = state.selection?.toggledSelection(event.chatId)) + } + Event.SelectionEnded -> { state -> state.copy(selection = null) } } } } diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt new file mode 100644 index 0000000000..b494bae493 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.analytics.ChatReadSource +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.chatResult +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatReadActions +import com.flipcash.shared.chat.ChatReadAllPlan +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.currentChatListFeeds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * What a chat list row, select mode and the "…" menu do, shared by the Chats and Archived lists: + * the read actions through [ChatReadActions], archive, bulk unmute, and the analytics each sends. + * + * Chats are looked up in the coordinator's feeds rather than taken from the rows, so a decision + * (unread or not, muted or not) reads the same state the rows were drawn from. + */ +class ChatRowActions @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val readActions: ChatReadActions, + private val archiveStore: ChatArchiveStore, + private val analytics: FlipcashAnalytics, +) { + + /** The main list's and the archived list's summaries for [ids], in [ids] order. */ + fun summaries(ids: Collection): List { + val feeds = chatCoordinator.currentChatListFeeds() + val all = (feeds?.main.orEmpty() + feeds?.archived.orEmpty()).associateBy { it.metadata.chatId } + return ids.mapNotNull { all[it] } + } + + /** The main list, as Read All sees it. */ + fun mainList(): List = chatCoordinator.currentChatListFeeds()?.main.orEmpty() + + /** The leading swipe: Mark as Read on an unread chat, Mark as Unread otherwise. */ + suspend fun toggleRead(chatId: ChatId, source: ChatReadSource) { + val chat = summaries(listOf(chatId)).firstOrNull() ?: return + if (chat.isUnread) { + readActions.markRead(chat) + analytics.track(ChatEvents.markedRead(chatType = chat.metadata.type.analytics, source = source)) + } else { + readActions.markUnread(chatId) + analytics.track(ChatEvents.markedUnread(chatType = chat.metadata.type.analytics, source = source)) + } + } + + /** Select mode's read toggle over [ids]: one event per selected chat. */ + suspend fun toggleRead(ids: Collection) { + val chats = summaries(ids) + val toggle = readActions.toggle(chats) + chats.forEach { chat -> + val chatType = chat.metadata.type.analytics + analytics.track( + when (toggle) { + ChatReadToggle.MarkRead -> ChatEvents.markedRead(chatType = chatType, source = ChatReadSource.SELECTION) + ChatReadToggle.MarkUnread -> ChatEvents.markedUnread(chatType = chatType, source = ChatReadSource.SELECTION) + } + ) + } + } + + /** Read All over the main list. */ + suspend fun readAll(): ChatReadAllPlan { + val plan = readActions.readAll(mainList()) + analytics.track(ChatEvents.readAll(count = plan.count)) + return plan + } + + /** Select mode's Unmute, offered only when every selected chat is muted. */ + suspend fun unmute(ids: Collection) = coroutineScope { + summaries(ids).forEach { chat -> + launch { + val result = chatCoordinator.unmute(chat.metadata.chatId) + analytics.track( + ChatEvents.unmuted( + chatType = chat.metadata.type.analytics, + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + ) + ) + } + } + } + + suspend fun archive(ids: Collection) = ids.forEach { archiveStore.archive(it) } + + suspend fun unarchive(ids: Collection) = ids.forEach { archiveStore.unarchive(it) } +} + +/** [ids]' chat types, by index, for the bulk mute sheet's per-chat events. */ +internal fun ChatRowActions.chatTypes(ids: List): Pair, List> { + val chats = summaries(ids) + return chats.map { it.metadata.chatId } to chats.map { it.metadata.type } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt new file mode 100644 index 0000000000..8db45dd07b --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.chat.isMutedAt +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.bulkMuteToggle +import com.flipcash.shared.chat.bulkReadToggle +import com.flipcash.shared.chat.ui.ConversationReference + +/** + * What select mode's bar offers for the selected rows: Mark as Read when any is unread (else Mark + * as Unread), and Unmute when every one is muted (else Mute). The rules are the shared ones. + */ +data class ChatSelectionActions( + val count: Int, + val read: ChatReadToggle, + val mute: ChatMuteToggle, +) { + companion object { + fun of(selected: List) = ChatSelectionActions( + count = selected.size, + read = bulkReadToggle(selected.map { it.isUnread }), + mute = bulkMuteToggle(selected.map { it.viewerState.isMutedAt() }), + ) + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 6dba8a2010..56d9bcec73 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -7,6 +7,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore @@ -25,6 +26,8 @@ import com.flipcash.shared.chat.chatListFeeds import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.shared.chat.currentChatListFeeds import com.flipcash.shared.chat.projectChatList +import com.flipcash.shared.chat.toggledSelection +import com.flipcash.analytics.ChatReadSource import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper @@ -53,6 +56,7 @@ open class ChatsViewModel @Inject constructor( private val lobbyRequests: LobbyRequests, private val hostLobbies: HostLobbies, private val paymentRequests: PaymentRequests, + private val rowActions: ChatRowActions, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -69,8 +73,24 @@ open class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, + // Select mode: the chats picked so far, or null outside it. Empty right after Select Chats. + val selection: Set? = null, ) { + val isSelecting: Boolean get() = selection != null + + /** The selected rows still in the main list, in list order. */ + val selectedChats: List + get() { + val ids = selection ?: return emptyList() + return chats.dataOrNull.orEmpty().filter { it.chatId in ids } + } + + val selectionActions: ChatSelectionActions get() = ChatSelectionActions.of(selectedChats) + + /** Read All has something to do: a main-list chat with unread messages, or marked. */ + val canReadAll: Boolean get() = chats.dataOrNull.orEmpty().any { it.isUnread } + /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -101,6 +121,12 @@ open class ChatsViewModel @Inject constructor( data class FilterSelected(val filter: ChatListFilter) : Event data object ChipsRevealed : Event + + data class SelectionStarted(val chatId: ChatId?) : Event + + data class SelectionToggled(val chatId: ChatId) : Event + + data object SelectionEnded : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -115,6 +141,62 @@ open class ChatsViewModel @Inject constructor( viewModelScope.launch { archiveStore.unarchive(chatId) } } + /** The leading swipe: Mark as Read or Mark as Unread, whichever the row offers. */ + fun toggleRead(chatId: ChatId) { + viewModelScope.launch { rowActions.toggleRead(chatId, ChatReadSource.SWIPE) } + } + + /** A long press (with that chat) or Select Chats (with none). */ + fun startSelection(chatId: ChatId? = null) = dispatchEvent(Event.SelectionStarted(chatId)) + + fun toggleSelection(chatId: ChatId) = dispatchEvent(Event.SelectionToggled(chatId)) + + fun endSelection() = dispatchEvent(Event.SelectionEnded) + + /** Select mode's read toggle. Leaves select mode. */ + fun readSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.toggleRead(ids) } } + + /** Select mode's Unmute, offered when every selected chat is muted. Leaves select mode. */ + fun unmuteSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unmute(ids) } } + + /** + * Select mode's Mute: the selection and its chat types for the one mute sheet, or null with + * nothing selected. Leaves select mode; the sheet applies the duration. + */ + fun muteSelection(): Pair, List>? { + var target: Pair, List>? = null + actOnSelection { ids -> target = rowActions.chatTypes(ids) } + return target + } + + /** Select mode's Archive: archives the selection and returns it, for the toast's Undo. */ + fun archiveSelection(): List { + var archived = emptyList() + actOnSelection { ids -> + archived = ids + viewModelScope.launch { rowActions.archive(ids) } + } + return archived + } + + fun unarchive(chatIds: List) { + viewModelScope.launch { rowActions.unarchive(chatIds) } + } + + /** + * Read All, once confirmed. The screen asks first (it moves the READ pointer, which everyone in + * those chats sees), so Cancel leaves everything as it was. + */ + fun readAll() { + viewModelScope.launch { rowActions.readAll() } + } + + private inline fun actOnSelection(action: (List) -> Unit) { + val ids = stateFlow.value.selectedChats.map { it.chatId } + if (ids.isNotEmpty()) action(ids) + endSelection() + } + init { // No push says a join request was answered; an admitted group only shows up once the feed // is synced, which refresh() does when GetChat says a request has left its lobby. @@ -145,7 +227,9 @@ open class ChatsViewModel @Inject constructor( archived = isArchived, muted = row.viewerState.isMutedAt(), hidden = false, - unread = row.unreadCount, + // A marked chat with nothing counted is unread by an unknown count, which the + // projection already counts as unread: one chat in the badge and the Unread chip. + unread = if (row.markedUnread && row.unreadCount == 0) null else row.unreadCount, pendingRequests = row.joinRequests.size, newestRequestMs = row.joinRequests.maxOfOrNull { it.enteredAt.toEpochMilliseconds() } ?: 0L, ) @@ -208,6 +292,11 @@ open class ChatsViewModel @Inject constructor( // A chip can only be tapped once the row is on screen, so choosing one reveals it. is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } + is Event.SelectionStarted -> { state -> state.copy(selection = setOfNotNull(event.chatId)) } + is Event.SelectionToggled -> { state -> + state.copy(selection = state.selection?.toggledSelection(event.chatId)) + } + Event.SelectionEnded -> { state -> state.copy(selection = null) } } } } diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt new file mode 100644 index 0000000000..c02de36ce2 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.tipping.internal.components + +import com.flipcash.core.resources.action_read +import com.flipcash.core.resources.action_unread +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.runtime.remember +import com.flipcash.services.models.chat.ChatId +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.action_markAsUnread +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SwipeAction +import com.getcode.util.resources.Res as UiRes +import com.getcode.util.resources.ic_mark_chat_read +import com.getcode.util.resources.ic_mark_chat_unread +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * Select mode's circles, shared by every row on a list: how far they have slid in, and which are + * checked. Held by the screen rather than each row, because a row is rebuilt when select mode drops + * its swipe wrapper, which would restart a slide kept inside it. + */ +internal class SelectCircles(val reveal: () -> Float, val checked: Set) + +@Composable +internal fun rememberSelectCircles(selection: Set?): SelectCircles { + val reveal = animateFloatAsState(if (selection != null) 1f else 0f, label = "select circles") + // Leaving select mode drops the selection while the circles are still sliding out, so they keep + // drawing the one they last had. Not state: it only changes when `selection` already has. + val last = remember { arrayOf(emptySet()) } + if (selection != null) last[0] = selection + return SelectCircles(reveal = { reveal.value }, checked = last[0]) +} + +/** + * The leading swipe on a chat list row: Mark as Read on an unread chat, Mark as Unread otherwise. + * Tonal like the trailing Mute circle: the glyph in the unread blue on its dark-blue ground, sized + * like the trailing Archive and Mute circles. The title under it is the short Read / Unread; the + * content description keeps the long Mark as Read / Mark as Unread. + */ +@Composable +internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pair { + val label = stringResource(if (isUnread) Res.string.action_markAsRead else Res.string.action_markAsUnread) + val title = stringResource(if (isUnread) Res.string.action_read else Res.string.action_unread) + val painter = painterResource( + if (isUnread) UiRes.drawable.ic_mark_chat_read else UiRes.drawable.ic_mark_chat_unread + ) + return SwipeAction( + background = CodeTheme.colors.indicatorSecondary, + onTriggered = onTriggered, + // The row stays in the list, so a full swipe has to send it home rather than leave it + // settled off screen. + resetOnDismiss = true, + label = title, + ) { + Icon( + painter = painter, + contentDescription = label, + tint = CodeTheme.colors.indicator, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } to label +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/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 index 123b0ff80d..9acf0f6598 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/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 @@ -4,7 +4,23 @@ 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.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.border +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Icon +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize @@ -35,24 +51,46 @@ import com.getcode.theme.CodeTheme import kotlin.time.Clock +/** Test tags for select mode's circle and the avatar it pushes right. */ +internal const val SELECT_CIRCLE_TAG = "chat_row_select_circle" +internal const val AVATAR_TAG = "chat_row_avatar" + @Composable internal fun TipChatRow( chat: ConversationReference, modifier: Modifier = Modifier, showDivider: Boolean = true, + // Select mode: whether this row is picked, or null outside select mode. In it a circle leads + // the row, the content shifts right to make room, and the chevron goes (a tap toggles). + selected: Boolean? = null, + // How far select mode's circle has slid in (0 to 1) and whether it is checked. The screen + // drives both so the slide survives the row being rebuilt when select mode starts or ends, and + // a circle sliding out keeps the state it had. + circleReveal: () -> Float = { if (selected != null) 1f else 0f }, + circleChecked: Boolean = selected == true, + onLongClick: (() -> Unit)? = null, onClick: () -> Unit, ) { + val selectedTint = CodeTheme.colors.surfaceVariant ChatListRow( - modifier = modifier, + modifier = modifier.drawWithContent { + drawContent() + // Over the row rather than behind it: the shared row paints its own opaque background. + if (selected == true) drawRect(selectedTint) + }, avatar = { - ContactAvatar( - image = chat.image, - displayName = chat.name.orEmpty(), - access = chat.avatarAccess, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x9) - .clip(CircleShape), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + SelectCircleSlot(circleReveal, circleChecked) + ContactAvatar( + modifier = Modifier + .testTag(AVATAR_TAG) + .requiredSize(CodeTheme.dimens.staticGrid.x9) + .clip(CircleShape), + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + ) + } }, title = { Row( @@ -81,7 +119,7 @@ internal fun TipChatRow( ChatRowTrailing( lastActivity = chat.rowActivity, unreadCount = chat.rowUnreadCount, - canOpen = true, + canOpen = selected == null, ) }, subtitle = { @@ -109,10 +147,71 @@ internal fun TipChatRow( ) }, showDivider = showDivider, + onLongClick = onLongClick, onClick = onClick, ) } +/** + * Select mode's leading circle. As select mode starts ([reveal] going 0 to 1) the slot widens and + * pushes the row content right, while the circle stays at its final position and fades in; as it + * ends, the reverse. The slot clips the circle, so the content sliding off uncovers it rather than + * overlapping it. Toggling a row changes the circle in place. Reads [reveal] in layout and draw + * only, so the slide doesn't recompose the row. + */ +@Composable +private fun SelectCircleSlot(reveal: () -> Float, checked: Boolean) { + Box( + Modifier + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = (placeable.width * reveal()).roundToInt() + // Placed at its final position for every width, so only the content moves. + layout(width, placeable.height) { placeable.placeRelative(0, 0) } + } + .graphicsLayer { alpha = reveal() } + ) { + Box( + Modifier + .testTag(SELECT_CIRCLE_TAG) + .padding(end = CodeTheme.dimens.staticGrid.x3) + ) { SelectCircle(checked) } + } +} + +/** + * An outline in the secondary text colour when not picked, a white check on the unread blue when + * picked. Both fill the same circle, so a toggle only crossfades between them. + */ +@Composable +private fun SelectCircle(selected: Boolean) { + val size = CodeTheme.dimens.staticGrid.x4 + Crossfade(targetState = selected, label = "select circle") { picked -> + if (picked) { + Box( + modifier = Modifier + .requiredSize(size) + .background(CodeTheme.colors.indicator, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x3), + ) + } + } else { + Box( + Modifier + .requiredSize(size) + .border(1.5.dp, CodeTheme.colors.textSecondary, CircleShape) + ) + } + } +} + /** * "Jasper wants to join" for one requester, "3 people want to join" for several, or null with * nobody waiting. Named the way the in-chat Join Requests anchor names them. diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index 53c4249fad..227780be44 100644 --- a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -87,6 +87,7 @@ class ChatsViewModelArchiveTest { lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = mockk(relaxed = true), ) val states = mutableListOf() diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt index e390a0b047..9fb7dfa22e 100644 --- a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -111,6 +111,7 @@ class ChatsViewModelRatesTest { lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = mockk(relaxed = true), ) val states = mutableListOf() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt index e26c2bd98d..b193f9225b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt @@ -44,3 +44,25 @@ fun MuteChatScreen(chatId: ChatId, chatType: ChatType) { onDismiss = dismissSheet, ) } + +/** + * The mute sheet for several chats, behind `AppRoute.Messaging.MuteChats`: the chat list's select + * mode opens it once for the whole selection. It always offers the durations (the selection holds + * at least one audible chat, or the bar would have offered Unmute), and the one picked applies to + * every chat in [chatIds], already muted ones included. + */ +@Composable +fun MuteChatsScreen(chatIds: List, chatTypes: List) { + val viewModel = hiltViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + MuteChatSheet( + isMuted = false, + onMute = { option -> + viewModel.muteAll(chatIds, chatTypes, option) + dismissSheet() + }, + onUnmute = dismissSheet, + onDismiss = dismissSheet, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 16debf9042..44c41f17a9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -69,6 +69,14 @@ internal class MuteChatViewModel @Inject constructor( .also { analytics.trackMuted(chatType, option, it) } } + /** + * The chat list's bulk Mute: [option] applied to every chat in [chatIds], already muted ones + * included. [chatTypes] pairs with [chatIds] by index, for each chat's own mute event. + */ + fun muteAll(chatIds: List, chatTypes: List, option: MuteOption) { + chatIds.zip(chatTypes).forEach { (chatId, chatType) -> mute(chatId, chatType, option) } + } + fun unmute(chatId: ChatId, chatType: ChatType) = request( errorTitle = R.string.error_title_failedToUnmute, errorDescription = R.string.error_description_failedToUnmute, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 2de3c84bad..6be075458e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -80,7 +80,7 @@ import com.getcode.ui.core.measured import com.getcode.ui.core.noRippleClickable import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff @Composable internal fun ChatTopBar( @@ -446,7 +446,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_silenceMember, firstName), - icon = SpeakerSlash, + icon = Icons.Outlined.MicOff, testTag = "action_silence_member", onClick = onSilence, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index e7f5bb8e9f..e5fd7f0c82 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.material.icons.filled.Lock import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.Dp -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff import dev.chrisbanes.haze.HazeState import androidx.compose.ui.text.style.TextAlign import kotlin.time.Duration.Companion.minutes @@ -571,7 +571,7 @@ internal fun GroupProfileMenu( } if (item == GroupProfileMenuAction.Moderation) { Submenu(label = stringResource(item.labelRes()), icon = item.icon(isPrivate)) { - ModerationEntry(R.string.title_silencedMembers, SpeakerSlash) { + ModerationEntry(R.string.title_silencedMembers, Icons.Outlined.MicOff) { onDismiss() onSilenced() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt index a281d19cb0..dbd0030beb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt @@ -32,7 +32,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.ChoiceRow import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff import kotlin.time.Instant /** @@ -138,7 +138,7 @@ internal fun MemberSheetContent( if (actions.restrict) { ChoiceRow( label = stringResource(R.string.action_silenceMemberShort), - icon = rememberVectorPainter(SpeakerSlash), + icon = rememberVectorPainter(Icons.Outlined.MicOff), contentColor = CodeTheme.colors.warningText, onClick = onRestrict, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt index 6e886f1447..d65a5af2de 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -61,7 +61,7 @@ import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.components.SwipeRevealGroup import com.getcode.ui.components.rememberSwipeRevealGroup import com.getcode.ui.theme.CodeScaffold -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff /** * Node 11210:70 — every member of a private group: the creator pinned first with a Host tag, then @@ -273,6 +273,11 @@ internal fun MemberSwipeRow( val restrictLabel = stringResource(R.string.content_description_restrictMember) val liftLabel = stringResource(R.string.content_description_liftRestriction) val removeLabel = stringResource(R.string.content_description_removeMember) + // Labels drawn under the swipe pills: the member sheet's titles, with Remove + // shortened since the row is already in the group. + val restrictTitle = stringResource(R.string.action_silenceMemberShort) + val liftTitle = stringResource(R.string.action_liftRestriction) + val removeTitle = stringResource(R.string.action_remove) val swipeActions = buildList { if (actions.restrict) { add( @@ -280,8 +285,9 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.warning.copy(alpha = 0.15f), onTriggered = onRestrict, resetOnDismiss = true, + label = restrictTitle, ) { - SwipeIcon(SpeakerSlash, restrictLabel, CodeTheme.colors.warning) + SwipeIcon(Icons.Outlined.MicOff, restrictLabel, CodeTheme.colors.warning) } ) } @@ -291,6 +297,7 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.surfaceVariant, onTriggered = onLift, resetOnDismiss = true, + label = liftTitle, ) { SwipeIcon(Icons.Outlined.LockOpen, liftLabel, CodeTheme.colors.textMain) } @@ -302,6 +309,7 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.error.copy(alpha = 0.15f), onTriggered = onRemove, resetOnDismiss = true, + label = removeTitle, ) { SwipeIcon(Icons.Outlined.PersonRemove, removeLabel, CodeTheme.colors.error) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index ff3eab1aab..82fe9e34be 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -24,7 +24,7 @@ import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff /** * What a DM counterparty's profile can act on. @@ -74,7 +74,7 @@ internal fun ChatProfileAction.icon(): ImageVector = when (this) { ChatProfileAction.Report -> Icons.Outlined.Feedback ChatProfileAction.Block -> Icons.Outlined.Block ChatProfileAction.Unblock -> Icons.Outlined.CheckCircle - ChatProfileAction.Silence -> SpeakerSlash + ChatProfileAction.Silence -> Icons.Outlined.MicOff ChatProfileAction.Remove -> Icons.Outlined.PersonRemove } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt index 4a79d5ba02..e276d5de33 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt @@ -69,6 +69,27 @@ class MuteChatAnalyticsTest { ) } + // Spec case 14: select mode's Mute opens the sheet once for the whole selection, and the one + // duration picked applies to every chat in it, the one already muted included. + @Test + fun `a bulk mute applies one duration to every selected chat`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.mute(any(), any()) } returns Result.success(Unit) + val alreadyMuted = ChatId(ByteArray(16) { 4 }.toList()) + + viewModel().muteAll(listOf(chatId, alreadyMuted), listOf(ChatType.TIP_DM, ChatType.GROUP), MuteOption.EightHours) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.mute(chatId, match { it is MuteState.Until }) } + coVerify(exactly = 1) { chatCoordinator.mute(alreadyMuted, match { it is MuteState.Until }) } + assertEquals( + listOf( + ChatEvents.muted(AnalyticsChatType.TIP, MuteDuration.EIGHT_HOURS, AnalyticsState.SUCCESS, error = null), + ChatEvents.muted(AnalyticsChatType.GROUP, MuteDuration.EIGHT_HOURS, AnalyticsState.SUCCESS, error = null), + ), + analytics.events, + ) + } + @Test fun `a refused mute reports the result name`() = runTest(mainCoroutineRule.dispatcher) { coEvery { chatCoordinator.mute(any(), any()) } returns Result.failure(MuteChatError.NotFound()) diff --git a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt index 257d29b6e1..bf94b2150a 100644 --- a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -12,22 +12,9 @@ import com.getcode.solana.keys.base58 import com.flipcash.analytics.ChatType as AnalyticsChatType import com.flipcash.analytics.DisplayNameSource as AnalyticsDisplayNameSource import com.flipcash.app.core.DisplayNameSource -import com.flipcash.services.models.AdmitLobbyMemberError -import com.flipcash.services.models.BanUserError -import com.flipcash.services.models.CreateInviteError -import com.flipcash.services.models.DenyLobbyMemberError -import com.flipcash.services.models.EditChatError -import com.flipcash.services.models.JoinChatError -import com.flipcash.services.models.LeaveChatError -import com.flipcash.services.models.MuteChatError -import com.flipcash.services.models.RevokeInviteError -import com.flipcash.services.models.SilenceUserError -import com.flipcash.services.models.StartChatError -import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType -import com.getcode.opencode.model.core.errors.ValidationException /* * Converts Android domain types into what the shared builders in `:libs:analytics-events` @@ -38,42 +25,6 @@ import com.getcode.opencode.model.core.errors.ValidationException val Throwable.analytics: String get() = message.orEmpty() -/** - * The `Error` property on the group and mute events: the chat RPC's result name as the proto - * spells it, or `Network` when the call failed in transport. Spelled out rather than taken from - * `javaClass.simpleName`, which R8 renames in release builds. - * - * `Validation` is the request failing proto validation before it was sent, which has no result - * name because the server never answered. - */ -val Throwable.chatResult: String - get() = when (this) { - is StartChatError.Denied, is EditChatError.Denied, is JoinChatError.Denied, - is LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied, - is CreateInviteError.Denied, is RevokeInviteError.Denied, is AdmitLobbyMemberError.Denied, - is DenyLobbyMemberError.Denied, is SilenceUserError.Denied, is BanUserError.Denied -> "Denied" - is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, - is MuteChatError.NotFound, is UnmuteChatError.NotFound, - is CreateInviteError.NotFound, is RevokeInviteError.NotFound, is AdmitLobbyMemberError.NotFound, - is DenyLobbyMemberError.NotFound, is SilenceUserError.NotFound, is BanUserError.NotFound -> "NotFound" - is CreateInviteError.TooManyInvites -> "TooManyInvites" - is CreateInviteError.InvalidExpiresAt -> "InvalidExpiresAt" - is CreateInviteError.InvalidSignature -> "InvalidSignature" - is AdmitLobbyMemberError.NotInLobby -> "NotInLobby" - is SilenceUserError.CannotRestrictCreator, is BanUserError.CannotRestrictCreator -> "CannotRestrictCreator" - is StartChatError.TitleModerated, is EditChatError.TitleModerated -> "TitleModerated" - is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" - is StartChatError.InvalidRules -> "InvalidRules" - is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" - is JoinChatError.Banned -> "Banned" - is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, - is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized, - is CreateInviteError.Unrecognized, is RevokeInviteError.Unrecognized, is AdmitLobbyMemberError.Unrecognized, - is DenyLobbyMemberError.Unrecognized, is SilenceUserError.Unrecognized, is BanUserError.Unrecognized -> "Unrecognized" - is ValidationException -> "Validation" - else -> "Network" - } - /** * The `Gate Mint` property: the mint a group's listener balance rule names, or null when the * group has no token gate — no rules, or a rule any currency satisfies. diff --git a/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt b/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt new file mode 100644 index 0000000000..4a91be5bad --- /dev/null +++ b/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt @@ -0,0 +1,51 @@ +package com.flipcash.app.analytics + +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.SilenceUserError +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.UnmuteChatError +import com.getcode.opencode.model.core.errors.ValidationException + +/** + * The `Error` property on the group and mute events: the chat RPC's result name as the proto + * spells it, or `Network` when the call failed in transport. Spelled out rather than taken from + * `javaClass.simpleName`, which R8 renames in release builds. + * + * `Validation` is the request failing proto validation before it was sent, which has no result + * name because the server never answered. + */ +val Throwable.chatResult: String + get() = when (this) { + is StartChatError.Denied, is EditChatError.Denied, is JoinChatError.Denied, + is LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied, + is CreateInviteError.Denied, is RevokeInviteError.Denied, is AdmitLobbyMemberError.Denied, + is DenyLobbyMemberError.Denied, is SilenceUserError.Denied, is BanUserError.Denied -> "Denied" + is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, + is MuteChatError.NotFound, is UnmuteChatError.NotFound, + is CreateInviteError.NotFound, is RevokeInviteError.NotFound, is AdmitLobbyMemberError.NotFound, + is DenyLobbyMemberError.NotFound, is SilenceUserError.NotFound, is BanUserError.NotFound -> "NotFound" + is CreateInviteError.TooManyInvites -> "TooManyInvites" + is CreateInviteError.InvalidExpiresAt -> "InvalidExpiresAt" + is CreateInviteError.InvalidSignature -> "InvalidSignature" + is AdmitLobbyMemberError.NotInLobby -> "NotInLobby" + is SilenceUserError.CannotRestrictCreator, is BanUserError.CannotRestrictCreator -> "CannotRestrictCreator" + is StartChatError.TitleModerated, is EditChatError.TitleModerated -> "TitleModerated" + is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" + is StartChatError.InvalidRules -> "InvalidRules" + is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" + is JoinChatError.Banned -> "Banned" + is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, + is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized, + is CreateInviteError.Unrecognized, is RevokeInviteError.Unrecognized, is AdmitLobbyMemberError.Unrecognized, + is DenyLobbyMemberError.Unrecognized, is SilenceUserError.Unrecognized, is BanUserError.Unrecognized -> "Unrecognized" + is ValidationException -> "Validation" + else -> "Network" + } diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt index a19c626bf8..c9067b8d66 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -4,6 +4,7 @@ import com.flipcash.core.resources.Res import com.flipcash.core.resources.label_chatReceipt_yesterday import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,6 +41,8 @@ fun ChatListRow( modifier: Modifier = Modifier, showDivider: Boolean = true, endAction: @Composable () -> Unit = {}, + /** A long press, for the chat list's select mode. Null leaves the row a plain click target. */ + onLongClick: (() -> Unit)? = null, onClick: () -> Unit, ) { Column( @@ -51,7 +54,13 @@ fun ChatListRow( modifier = Modifier .fillMaxWidth() .testTag("send_contact_row") - .clickable(onClick = onClick) + .then( + if (onLongClick != null) { + Modifier.combinedClickable(onLongClick = onLongClick, onClick = onClick) + } else { + Modifier.clickable(onClick = onClick) + } + ) .padding( horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3, diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index e70c871db9..1be1c41a3d 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -94,6 +94,7 @@ fun ChatSummary.toConversationReference( lastActivity = metadata.lastActivity, unreadCount = unreadCount, viewerState = metadata.viewerState, + markedUnread = markedUnread, ) } diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 2f2af7347e..4b2ef76c9e 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -66,7 +66,12 @@ data class ConversationReference( * otherwise. Requests read as unread on the row: they add to its count and move its time. */ val joinRequests: List = emptyList(), + /** Marked unread on this device ([com.flipcash.shared.chat.ChatMarkedUnreadStore]). */ + val markedUnread: Boolean = false, ) { + /** Unread messages, or marked unread: what the read actions and Read All go by. */ + val isUnread: Boolean get() = unreadCount != 0 || markedUnread + /** The time the row shows and sorts by: the later of [lastActivity] and the newest request. */ val rowActivity: Instant? get() { @@ -76,9 +81,15 @@ data class ConversationReference( /** * The row's unread pill: unread messages plus pending requests. A null [unreadCount] stays - * null (unread by an unknown count), since adding to an unknown number gives an unknown one. + * null (unread by an unknown count), since adding to an unknown number gives an unknown one, + * and so does a [markedUnread] chat with nothing to count. */ - val rowUnreadCount: Int? get() = unreadCount?.plus(joinRequests.size) + val rowUnreadCount: Int? + get() { + val count = unreadCount?.plus(joinRequests.size) ?: return null + // A marked chat with nothing counted draws the no-count dot; a known count wins. + return if (count == 0 && markedUnread) null else count + } /** * What to call this row: the chat's [title] for a group, otherwise the counterparty's diff --git a/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt new file mode 100644 index 0000000000..55874ee933 --- /dev/null +++ b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.chat + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.init +import com.flipcash.app.persistence.sources.ChatMarkedUnreadDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.internal.RealChatMarkedUnreadStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class RealChatMarkedUnreadStoreTest { + + private val a = ChatId("aabbccdd") + private val b = ChatId("11223344") + private val c = ChatId("55667788") + private val store: ChatMarkedUnreadStore = RealChatMarkedUnreadStore(ChatMarkedUnreadDataSource()) { 1_000L } + + private fun signIn(entropy: String) = + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), entropy) + + @Before + fun setUp() { + signIn(ACCOUNT_X) + runBlocking { store.clearAll() } + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + @Test + fun `mark adds the chat and observeMarked reports it`() = runBlocking { + store.mark(a) + assertEquals(setOf(a), store.observeMarked().first()) + } + + @Test + fun `clear removes only that chat`() = runBlocking { + store.mark(a) + store.mark(b) + store.clear(a) + assertEquals(setOf(b), store.observeMarked().first()) + } + + @Test + fun `clear by set removes only those chats`() = runBlocking { + listOf(a, b, c).forEach { store.mark(it) } + store.clear(listOf(a, c)) + assertEquals(setOf(b), store.observeMarked().first()) + } + + // Test case 8: marks survive logout, per account. The database file is per account, so + // logging out closes X's marks away from Y and signing back in as X brings them back. + @Test + fun `marks survive logout per account`() = runBlocking { + store.mark(a) + store.mark(b) + + FlipcashDatabase.closeDb() + signIn(ACCOUNT_Y) + assertEquals(emptySet(), store.observeMarked().first()) + + FlipcashDatabase.closeDb() + signIn(ACCOUNT_X) + assertEquals(setOf(a, b), store.observeMarked().first()) + } + + // Erasing the cache (clearCache) wipes the marks. + @Test + fun `clearAll empties the marked set`() = runBlocking { + store.mark(a) + store.mark(b) + store.clearAll() + assertEquals(emptySet(), store.observeMarked().first()) + } + + @Test + fun `no database means nothing marked`() = runBlocking { + FlipcashDatabase.closeDb() + assertEquals(emptySet(), store.observeMarked().first()) + } + + private companion object { + // The database file is named from the first 6 bytes of the entropy, so these differ there. + const val ACCOUNT_X = "xxxxxx-chat-marked-unread-store-test" + const val ACCOUNT_Y = "yyyyyy-chat-marked-unread-store-test" + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt index ee2fdc8246..aea43d91d7 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -21,12 +21,12 @@ fun FeedOperations.chatListFeed(): Flow> = feed(*chatListTypes /** [FeedOperations.currentFeed] for the chats the Chats list shows. */ fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chatListTypes) -/** How many of the chats the Chats list shows have unread messages. */ +/** How many of the chats the Chats list shows are unread: unread messages or marked unread. */ fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) /** - * The Chats tab badge: the chats the list shows that read as unread, which is unread messages or, - * on a group [selfId] hosts, someone waiting in [lobbies] to join. Counted the way + * The Chats tab badge: the chats the list shows that read as unread, which is unread messages, a mark, + * or, on a group [selfId] hosts, someone waiting in [lobbies] to join. Counted the way * [projectChatList] counts its Unread chip, so the badge and the chip agree. */ fun FeedOperations.observeUnreadChatListCount( @@ -34,7 +34,7 @@ fun FeedOperations.observeUnreadChatListCount( selfId: () -> ID?, ): Flow = combine(feed(*chatListTypes), lobbies) { summaries, waiting -> val self = selfId() - summaries.count { it.unreadCount != 0 || waiting.waitingFor(it.metadata, self).isNotEmpty() } + summaries.count { it.isUnread || waiting.waitingFor(it.metadata, self).isNotEmpty() } } /** [FeedOperations.archivedFeed] for the chats the Chats list shows. */ diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt new file mode 100644 index 0000000000..5699396742 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt @@ -0,0 +1,42 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * The chats the viewer marked as unread, kept on this device. + * + * A mark is set only by Mark as Unread. It is cleared by Mark as Read, Read All, opening the chat, + * leaving a group and account erasure. A new message in a marked chat leaves the mark alone, as + * do archive and unarchive. + * + * Every caller goes through this interface, so a server-backed store can replace this local one + * without a caller changing. Nothing here reaches the network. + */ +interface ChatMarkedUnreadStore { + + /** The marked chats, re-emitted on every change. Emits an empty set before sign-in. */ + fun observeMarked(): Flow> + + /** Marks [chatId]. Marking a marked chat does nothing. */ + suspend fun mark(chatId: ChatId) + + /** Clears the mark on [chatId], if it has one. */ + suspend fun clear(chatId: ChatId) + + /** Clears the mark on each of [chatIds]. Read All and bulk Mark as Read. */ + suspend fun clear(chatIds: Collection) + + /** Drops every mark. Account erasure. */ + suspend fun clearAll() + + /** Marks nothing and ignores writes; the default for constructors tests build directly. */ + object None : ChatMarkedUnreadStore { + override fun observeMarked(): Flow> = flowOf(emptySet()) + override suspend fun mark(chatId: ChatId) = Unit + override suspend fun clear(chatId: ChatId) = Unit + override suspend fun clear(chatIds: Collection) = Unit + override suspend fun clearAll() = Unit + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt new file mode 100644 index 0000000000..2d4a146ea2 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt @@ -0,0 +1,53 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Mark as Read, Mark as Unread and Read All, for the chat list. Marks go through + * [ChatMarkedUnreadStore]; READ goes through the coordinator's [ChatCoordinator.markAsRead]. + * Mark as Unread sends nothing. + */ +class ChatReadActions internal constructor( + private val marks: ChatMarkedUnreadStore, + private val advanceRead: suspend (ChatId) -> Unit, +) { + + @Inject + constructor(coordinator: ChatCoordinator, marks: ChatMarkedUnreadStore) : + this(marks, { chatId -> coordinator.markAsRead(chatId) }) + + suspend fun markUnread(chatId: ChatId) = marks.mark(chatId) + + suspend fun markRead(chat: ChatSummary) { + val change = chat.markReadChange() + if (change.clearMark) marks.clear(chat.metadata.chatId) + if (change.advance) advanceRead(chat.metadata.chatId) + } + + /** Applies the read toggle to [chats]: [ChatReadToggle.MarkRead] if any is unread. */ + suspend fun toggle(chats: List): ChatReadToggle { + val toggle = bulkReadToggle(chats.map { it.isUnread }) + coroutineScope { + chats.forEach { chat -> + launch { + when (toggle) { + ChatReadToggle.MarkRead -> markRead(chat) + ChatReadToggle.MarkUnread -> markUnread(chat.metadata.chatId) + } + } + } + } + return toggle + } + + /** Read All over [main], the main list. Returns what it did. */ + suspend fun readAll(main: List): ChatReadAllPlan { + val plan = readAllPlan(main) + marks.clear(plan.clearMarks) + coroutineScope { plan.advance.forEach { launch { advanceRead(it) } } } + return plan + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt new file mode 100644 index 0000000000..a96d0befc1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt @@ -0,0 +1,75 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId + +/** The one read action a chat offers in the slot its swipe and select mode share. */ +enum class ChatReadToggle { MarkRead, MarkUnread } + +/** Mark as Read when the chat is unread (messages or a mark), Mark as Unread otherwise. */ +fun readToggleFor(isUnread: Boolean): ChatReadToggle = + if (isUnread) ChatReadToggle.MarkRead else ChatReadToggle.MarkUnread + +/** + * Select mode's read action: Mark as Read when any selected chat is unread, applied to every + * selected chat; otherwise Mark as Unread, applied to every selected chat. + */ +fun bulkReadToggle(selectedUnread: Iterable): ChatReadToggle = + readToggleFor(selectedUnread.any { it }) + +/** Select mode's mute action. */ +enum class ChatMuteToggle { Mute, Unmute } + +/** + * Unmute only when every selected chat is muted. Otherwise Mute, which opens the mute sheet once + * and applies its duration to every selected chat, ones already muted included. + */ +fun bulkMuteToggle(selectedMuted: Collection): ChatMuteToggle = + if (selectedMuted.isNotEmpty() && selectedMuted.all { it }) ChatMuteToggle.Unmute else ChatMuteToggle.Mute + +/** + * What Mark as Read does to one chat. [advance]: it has unread messages, so READ moves to the + * latest message, the same call opening the chat makes. [clearMark]: it is marked. A marked chat + * with nothing unread sends nothing. + */ +data class ChatReadChange(val advance: Boolean, val clearMark: Boolean) + +fun ChatSummary.markReadChange(): ChatReadChange = + ChatReadChange(advance = unreadCount != 0, clearMark = markedUnread) + +/** + * What Read All does to the main list: advance READ on every chat with unread messages (muted + * ones included) and clear every mark. Archived chats are not in the main list, so their marks stay. + * + * Read All is enabled exactly when this plan is not empty, so the menu and the confirm agree. + * + * @property count the chats whose READ advanced or mark cleared, each counted once: the `Count` + * on Chats Read All. + */ +data class ChatReadAllPlan(val advance: List, val clearMarks: List) { + val count: Int get() = (advance + clearMarks).toSet().size + val isEmpty: Boolean get() = advance.isEmpty() && clearMarks.isEmpty() +} + +fun readAllPlan(main: List): ChatReadAllPlan { + val changes = main.map { it.metadata.chatId to it.markReadChange() } + return ChatReadAllPlan( + advance = changes.filter { it.second.advance }.map { it.first }, + clearMarks = changes.filter { it.second.clearMark }.map { it.first }, + ) +} + +/** + * The count the bulk archive toast names: "Chat archived" for one, "%d chats archived" for more. + * Null when nothing was archived, so no toast is shown. + */ +fun archivedToastCount(archived: Int): Int? = archived.takeIf { it > 0 } + +/** + * Select mode's tap on [chatId]: adds it, or takes it out. Null when that takes out the last + * selected chat, which leaves select mode. (Select Chats enters with nothing selected; only a + * deselect empties it.) + */ +fun Set.toggledSelection(chatId: ChatId): Set? { + if (chatId !in this) return this + chatId + return (this - chatId).ifEmpty { null } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt index 8aaea83eea..1a70f258fb 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -25,13 +25,24 @@ data class ChatState( // The chats the viewer archived, read in the same combine that builds [feed] so the feed and // the archive set always change together. `feed` and `archivedFeed` split on it. val archived: Set = emptySet(), + // The chats the viewer marked as unread, read in that same combine for the same reason. + val markedUnread: Set = emptySet(), ) data class ChatSummary( val metadata: ChatMetadata, /** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */ val unreadCount: Int?, -) + /** The viewer marked the chat as unread. Kept apart from [unreadCount], which stays the real count. */ + val markedUnread: Boolean = false, +) { + /** + * Unread messages or a mark. The one test every unread count uses (the Chats tab badge, the + * Unread chip, Read All), so a marked chat counts once wherever it counts. + */ + val isUnread: Boolean + get() = unreadCount != 0 || markedUnread +} data class ActiveTypist( val userId: ID, diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 2c3948d6f2..9a5a67718a 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatMarkedUnreadStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.HostLobbies @@ -24,6 +25,7 @@ import com.flipcash.shared.chat.internal.MentionSuggestionPool import com.flipcash.shared.chat.internal.ServerRosterSearchSource import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.internal.RealChatArchiveStore +import com.flipcash.shared.chat.internal.RealChatMarkedUnreadStore import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.flipcash.app.persistence.sources.GroupKeyEnvelopeDataSource @@ -73,6 +75,12 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore + @Binds + @Singleton + abstract fun bindChatMarkedUnreadStore( + impl: RealChatMarkedUnreadStore + ): ChatMarkedUnreadStore + @Binds internal abstract fun bindLobbyRequestStore( impl: DataStoreLobbyRequestStore diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 300ce54952..aad9c465b8 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.AppForeground import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatMarkedUnreadStore import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.PaymentRequests @@ -110,6 +111,7 @@ class RealChatCoordinator @Inject constructor( private val hostInvites: HostInvites? = null, private val groupKeyEnvelopes: GroupKeyEnvelopeStore? = null, private val appForeground: AppForeground = AppForeground.None, + private val markedUnreadStore: ChatMarkedUnreadStore = ChatMarkedUnreadStore.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -260,6 +262,17 @@ class RealChatCoordinator @Inject constructor( .launchIn(scope) } + /** + * Opening a chat clears its unread mark. Every way into a chat (a row, a notification, a link) + * makes the chat screen call this, so this is the one place the mark is cleared on open. + * [onStart] restores a backgrounded chat through the delegate directly: returning to a chat + * the viewer already had open is not opening it again. + */ + override fun setActiveChatId(chatId: ChatId?) { + messagingDelegate.setActiveChatId(chatId) + if (chatId != null) scope.launch { markedUnreadStore.clear(chatId) } + } + /** * Also drops the backgrounded stash, which [onStop] fills and [onStart] restores from. A chat * screen destroyed while the app is away is gone by the time the app returns; without this it @@ -342,6 +355,8 @@ class RealChatCoordinator @Inject constructor( // record is left behind, harmlessly, because the chat is no longer a member chat and // so is in neither feed. archiveStore.unarchive(chatId) + // And its unread mark, for the same reason. + markedUnreadStore.clear(chatId) // The stored requests went with the membership change; this is the in-memory copy. // A removal or remote leave has no path here, and its cards stay until the session ends. paymentRequests?.forget(chatId) @@ -451,6 +466,7 @@ class RealChatCoordinator @Inject constructor( // the same file — and account deletion is the one path that is meant to erase them. draftStore.clearAll() archiveStore.clearAll() + markedUnreadStore.clearAll() paymentRequests?.clearStored() // Wrapped chat keys, stored per account like the rows above. groupKeyEnvelopes?.clear() diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt new file mode 100644 index 0000000000..f4d7c53b0d --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatMarkedUnreadDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatMarkedUnreadStore +import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** [ChatMarkedUnreadStore] over the `chat_marked_unread` table. */ +@Singleton +class RealChatMarkedUnreadStore internal constructor( + private val dataSource: ChatMarkedUnreadDataSource, + private val now: () -> Long, +) : ChatMarkedUnreadStore { + + @Inject + constructor(dataSource: ChatMarkedUnreadDataSource) : this(dataSource, System::currentTimeMillis) + + override fun observeMarked(): Flow> = + dataSource.observeIds().map { hexes -> hexes.mapTo(HashSet()) { ChatId(it) } } + + override suspend fun mark(chatId: ChatId) = dataSource.mark(chatId.hex, now()) + + override suspend fun clear(chatId: ChatId) = dataSource.clear(chatId.hex) + + override suspend fun clear(chatIds: Collection) = dataSource.clear(chatIds.map { it.hex }) + + override suspend fun clearAll() = dataSource.clearAll() + + // Matches ChatEntityMapper.chatIdHex, as RealChatArchiveStore does. + private val ChatId.hex: String + get() = bytes.toList().hexEncodedString() +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 4dd856055c..d9aa337b6e 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -23,6 +23,7 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatMarkedUnreadStore import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -84,6 +85,7 @@ class FeedSyncDelegate @Inject constructor( private val userManager: UserManager, private val messagingController: ChatMessagingController, private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, + private val markedUnreadStore: ChatMarkedUnreadStore = ChatMarkedUnreadStore.None, private val feedWriter: ChatFeedWriter = ChatFeedWriter(metadataDataSource, memberDataSource, messageDataSource), ) : FeedOperations { @@ -210,12 +212,12 @@ class FeedSyncDelegate @Inject constructor( .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> val count = unreadCount(metadata, selfId) { id -> state.readStampAt(metadata.chatId, id) } - ChatSummary(metadata = metadata, unreadCount = count) + ChatSummary(metadata = metadata, unreadCount = count, markedUnread = metadata.chatId in state.markedUnread) } } override fun observeUnreadConversations(vararg chatTypes: ChatType): Flow { - return feed(*chatTypes).map { summaries -> summaries.count { it.unreadCount != 0 } } + return feed(*chatTypes).map { summaries -> summaries.count { it.isUnread } } } override fun feedPaged(vararg chatTypes: ChatType): Flow> { @@ -294,9 +296,10 @@ class FeedSyncDelegate @Inject constructor( .onStart { emit(emptyList()) } .distinctUntilChanged(), archiveStore.observeArchived(), - ) { metadataEntities, membersByChat, _, archived -> - buildFeedFromDb(metadataEntities, membersByChat) to archived - }.onEach { (built, archived) -> + markedUnreadStore.observeMarked(), + ) { metadataEntities, membersByChat, _, archived, marked -> + Triple(buildFeedFromDb(metadataEntities, membersByChat), archived, marked) + }.onEach { (built, archived, marked) -> val (feed, readStamps) = built val staged = drainStagedStamps() stateHolder.update { @@ -305,6 +308,7 @@ class FeedSyncDelegate @Inject constructor( readStamps = readStamps, fetchedReadStamps = if (staged.isEmpty()) it.fetchedReadStamps else it.fetchedReadStamps + staged, archived = archived, + markedUnread = marked, ) } // Until the first sync settles, the reconcile looks the stamps up itself, bounded and diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 0cdb0acab1..7cf398551f 100644 --- a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -51,6 +51,8 @@ class ChatCoordinatorTeardownTest { private lateinit var memberDataSource: ChatMemberDataSource private lateinit var draftStore: ChatDraftStore private lateinit var archiveStore: ChatArchiveStore + private lateinit var markedUnreadStore: ChatMarkedUnreadStore + private val scheduler = TestCoroutineScheduler() private lateinit var groupFeedDelegate: GroupFeedDelegate private lateinit var hostInvites: HostInvites private lateinit var groupKeyEnvelopes: com.flipcash.services.chat.GroupKeyEnvelopeStore @@ -74,6 +76,7 @@ class ChatCoordinatorTeardownTest { memberDataSource = mockk(relaxed = true) draftStore = mockk(relaxed = true) archiveStore = mockk(relaxed = true) + markedUnreadStore = mockk(relaxed = true) groupFeedDelegate = mockk(relaxed = true) hostInvites = mockk(relaxed = true) groupKeyEnvelopes = mockk(relaxed = true) @@ -124,10 +127,11 @@ class ChatCoordinatorTeardownTest { draftStore = draftStore, userManager = userManager, networkObserver = mockk(relaxed = true), - dispatchers = TestDispatchers(TestCoroutineScheduler()), + dispatchers = TestDispatchers(scheduler), groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), archiveStore = archiveStore, + markedUnreadStore = markedUnreadStore, hostInvites = hostInvites, groupKeyEnvelopes = groupKeyEnvelopes, ) @@ -218,4 +222,55 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { archiveStore.unarchive(any()) } } + + // Like the archive set: logout keeps the marks (the database is per account; test case 8 is in + // RealChatMarkedUnreadStoreTest), erasure wipes them. + @Test + fun `clearCache drops the unread marks, teardown keeps them`() = runTest { + coordinator.teardown() + coVerify(exactly = 0) { markedUnreadStore.clearAll() } + + coordinator.clearCache() + coVerify(exactly = 1) { markedUnreadStore.clearAll() } + } + + // Test case 7. + @Test + fun `a successful leave clears the unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.success(Unit) + + coordinator.leave(chatId) + + coVerify(exactly = 1) { markedUnreadStore.clear(chatId) } + } + + @Test + fun `a failed leave keeps the unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.failure(RuntimeException("nope")) + + coordinator.leave(chatId) + + coVerify(exactly = 0) { markedUnreadStore.clear(any()) } + } + + // Test case 4: every way into a chat makes the chat screen call setActiveChatId. + @Test + fun `opening a chat clears its unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + + coordinator.setActiveChatId(chatId) + scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { markedUnreadStore.clear(chatId) } + } + + @Test + fun `releasing the active chat clears nothing`() = runTest { + coordinator.setActiveChatId(null) + scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { markedUnreadStore.clear(any()) } + } } diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt new file mode 100644 index 0000000000..c1213b6c99 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt @@ -0,0 +1,205 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The unread mark rides the same combine as archive: a marked chat reads as unread in the feed and + * the tab badge, and its real count still shows when it has one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatMarkedUnreadFeedTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val tipHex = "aabbccdd" + private val groupHex = "11223344" + + private fun entity(hex: String, type: ChatType) = ChatMetadataEntity( + chatIdHex = hex, + chatType = type.name, + lastActivityEpochMs = 2_000, + lastMessageId = 2, + isMember = true, + ) + + private fun message(id: Long) = ChatMessage( + messageId = id, + senderId = otherId, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = id, + ) + + private val selfUnread = ChatMember( + userId = selfId, + userProfile = UserProfile.Empty, + pointers = listOf( + MessagePointer(PointerType.READ, selfId, value = 1, timestamp = Instant.fromEpochSeconds(1_000)) + ), + ) + + private val selfRead = selfUnread.copy( + pointers = listOf( + MessagePointer(PointerType.READ, selfId, value = 2, timestamp = Instant.fromEpochSeconds(1_000)) + ), + ) + + // A tip DM renders only when the other member is addressable, so give them a verified phone. + private val tipOther = ChatMember( + userId = otherId, + userProfile = UserProfile.Empty.copy( + displayName = "Ada", + phoneNumber = VerifiableContactMethod("+15551234567", verified = true), + ), + pointers = emptyList(), + ) + + private class Fixture( + val delegate: FeedSyncDelegate, + val archived: MutableStateFlow>, + val marked: MutableStateFlow>, + ) + + private fun TestScope.fixture(initiallyArchived: Set = emptySet(), initiallyMarked: Set = emptySet()): Fixture { + val entities = listOf(entity(tipHex, ChatType.TIP_DM), entity(groupHex, ChatType.GROUP)) + val members = mapOf(tipHex to listOf(selfRead, tipOther), groupHex to listOf(selfUnread)) + + val messageDataSource = mockk(relaxed = true).also { + coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } + coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() } + } + val metadataDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(entities) + every { source.toMetadata(any(), any(), any()) } answers { + val row = firstArg() + ChatMetadata( + chatId = ChatId(row.chatIdHex), + type = ChatType.valueOf(row.chatType), + members = secondArg(), + lastMessage = thirdArg(), + lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), + title = row.title, + rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion), + ) + } + } + val memberDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(members) + } + + val archived = MutableStateFlow(initiallyArchived) + val archiveStore = object : ChatArchiveStore by ChatArchiveStore.None { + override fun observeArchived() = archived + } + + val marked = MutableStateFlow(initiallyMarked) + val markStore = object : ChatMarkedUnreadStore by ChatMarkedUnreadStore.None { + override fun observeMarked() = marked + } + + val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), + chatController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = ChatStateHolder().apply { update { it.copy(feedSyncState = FeedSyncState.Synced) } }, + userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns null + }, + archiveStore = archiveStore, + markedUnreadStore = markStore, + ) + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + return Fixture(delegate, archived, marked) + } + + private val listTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP) + private val tip = ChatId(tipHex) + private val group = ChatId(groupHex) + + private suspend fun FeedSyncDelegate.summary(id: ChatId) = + feed(*listTypes).first().single { it.metadata.chatId == id } + + // Test case 1: the read tip DM, once marked, is unread with no count and lifts the badge by one. + @Test + fun `marking a read chat makes it unread with no count and adds one to the badge`() = runTest { + val f = fixture() + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + assertFalse(f.delegate.summary(tip).isUnread) + + f.marked.value = setOf(tip) + runCurrent() + + val summary = f.delegate.summary(tip) + assertEquals(0, summary.unreadCount) + assertTrue(summary.markedUnread) + assertTrue(summary.isUnread) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } + + // Test case 5: the group has one unread message and a mark; the count shows, the badge counts it once. + @Test + fun `a marked chat with unread messages keeps its count and counts once`() = runTest { + val f = fixture(initiallyMarked = setOf(group)) + val summary = f.delegate.summary(group) + assertEquals(1, summary.unreadCount) + assertTrue(summary.markedUnread) + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + assertEquals(1, f.delegate.observeUnreadChatListCount(flowOf(emptyMap())) { selfId }.first()) + } + + // Test case 6: archive and unarchive leave the mark alone. + @Test + fun `a marked chat stays marked through archive and unarchive`() = runTest { + val f = fixture(initiallyMarked = setOf(tip)) + + f.archived.value = setOf(tip) + runCurrent() + assertTrue(f.delegate.archivedFeed(*listTypes).first().single { it.metadata.chatId == tip }.markedUnread) + // An archived chat leaves the badge, mark or not. + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + + f.archived.value = emptySet() + runCurrent() + assertTrue(f.delegate.summary(tip).markedUnread) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } +} diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt new file mode 100644 index 0000000000..13cc6861a9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt @@ -0,0 +1,157 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** The chat-list read rules and the actions that apply them. Numbers are the spec's test cases. */ +class ChatReadActionsTest { + + private class FakeMarks(initial: Set = emptySet()) : ChatMarkedUnreadStore { + val marked = MutableStateFlow(initial) + override fun observeMarked(): Flow> = marked + override suspend fun mark(chatId: ChatId) { marked.value += chatId } + override suspend fun clear(chatId: ChatId) { marked.value -= chatId } + override suspend fun clear(chatIds: Collection) { marked.value -= chatIds.toSet() } + override suspend fun clearAll() { marked.value = emptySet() } + } + + private val a = ChatId("aa00") + private val b = ChatId("bb00") + private val c = ChatId("cc00") + private val e = ChatId("ee00") + + private fun chat(id: ChatId, unread: Int?, marked: Boolean = false) = ChatSummary( + metadata = ChatMetadata( + chatId = id, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + ), + unreadCount = unread, + markedUnread = marked, + ) + + private val advanced = mutableListOf() + private fun actions(marks: FakeMarks) = ChatReadActions(marks) { advanced += it } + + // 1 + @Test + fun `mark as unread on a read chat marks it and sends nothing`() = runTest { + val marks = FakeMarks() + assertEquals(ChatReadToggle.MarkUnread, readToggleFor(chat(c, 0).isUnread)) + actions(marks).markUnread(c) + assertEquals(setOf(c), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 2 + @Test + fun `mark as read on a marked chat with nothing unread clears the mark and sends nothing`() = runTest { + val marks = FakeMarks(setOf(b)) + val chat = chat(b, 0, marked = true) + assertEquals(ChatReadToggle.MarkRead, readToggleFor(chat.isUnread)) + actions(marks).markRead(chat) + assertEquals(emptySet(), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 3 + @Test + fun `a chat with unread messages offers mark as read, which advances READ`() = runTest { + val chat = chat(a, 3) + assertEquals(ChatReadToggle.MarkRead, readToggleFor(chat.isUnread)) + actions(FakeMarks()).markRead(chat) + assertEquals(listOf(a), advanced) + } + + @Test + fun `an unknown unread count reads as unread and advances`() = runTest { + val chat = chat(a, null) + assertTrue(chat.isUnread) + actions(FakeMarks()).markRead(chat) + assertEquals(listOf(a), advanced) + } + + // 9 + @Test + fun `read all advances unread, clears marks, leaves read chats and archived marks`() = runTest { + val d = ChatId("dd00") + val marks = FakeMarks(setOf(b, d)) + val main = listOf(chat(a, 2), chat(b, 0, marked = true), chat(c, 0)) + + val plan = actions(marks).readAll(main) + + assertEquals(listOf(a), advanced) + assertEquals(setOf(d), marks.marked.value) + assertEquals(2, plan.count) + } + + @Test + fun `read all counts a chat that is both unread and marked once`() { + assertEquals(1, readAllPlan(listOf(chat(a, 2, marked = true))).count) + } + + // 10 + @Test + fun `read all has nothing to do when nothing in the main list is unread`() { + assertTrue(readAllPlan(listOf(chat(c, 0), chat(e, 0))).isEmpty) + assertFalse(readAllPlan(listOf(chat(c, 0), chat(b, 0, marked = true))).isEmpty) + } + + // 12 + @Test + fun `selection with an unread chat offers mark as read and reads both`() = runTest { + val marks = FakeMarks() + val toggle = actions(marks).toggle(listOf(chat(a, 1), chat(c, 0))) + assertEquals(ChatReadToggle.MarkRead, toggle) + assertEquals(listOf(a), advanced) + assertEquals(emptySet(), marks.marked.value) + } + + @Test + fun `selection mark as read clears a selected mark too`() = runTest { + val marks = FakeMarks(setOf(b)) + actions(marks).toggle(listOf(chat(b, 0, marked = true), chat(c, 0))) + assertEquals(emptySet(), marks.marked.value) + } + + // 13 + @Test + fun `selection of read chats offers mark as unread and marks both`() = runTest { + val marks = FakeMarks() + val toggle = actions(marks).toggle(listOf(chat(c, 0), chat(e, 0))) + assertEquals(ChatReadToggle.MarkUnread, toggle) + assertEquals(setOf(c, e), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 14 (the sheet and the duration are the screen's; this is the label) + @Test + fun `a mixed muted selection offers mute`() { + assertEquals(ChatMuteToggle.Mute, bulkMuteToggle(listOf(true, false))) + assertEquals(ChatMuteToggle.Mute, bulkMuteToggle(emptyList())) + } + + // 15 + @Test + fun `an all-muted selection offers unmute`() { + assertEquals(ChatMuteToggle.Unmute, bulkMuteToggle(listOf(true, true))) + } + + // 16 (the label count) + @Test + fun `the archive toast count is the number archived`() { + assertEquals(2, archivedToastCount(2)) + assertEquals(null, archivedToastCount(0)) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json new file mode 100644 index 0000000000..b338880b72 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json @@ -0,0 +1,1255 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "a26e7172f36684cb72a298672d897014", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `can_pin` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canPin", + "columnName": "can_pin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_marked_unread", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `marked_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedAt", + "columnName": "marked_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + }, + { + "tableName": "group_key_envelopes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `own_pk_hex` TEXT NOT NULL, `scheme` INTEGER NOT NULL, `nonce` BLOB NOT NULL, `ciphertext` BLOB NOT NULL, `wrapped_by` BLOB, `wrapped_by_public_key` BLOB, PRIMARY KEY(`chat_id_hex`, `own_pk_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownPkHex", + "columnName": "own_pk_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheme", + "columnName": "scheme", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "wrappedBy", + "columnName": "wrapped_by", + "affinity": "BLOB" + }, + { + "fieldPath": "wrappedByPublicKey", + "columnName": "wrapped_by_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "own_pk_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a26e7172f36684cb72a298672d897014')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt b/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt new file mode 100644 index 0000000000..dc40941837 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt @@ -0,0 +1,64 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatMarkedUnreadEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class ChatMarkedUnreadDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ChatMarkedUnreadDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + FlipcashDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.chatMarkedUnreadDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `marking twice keeps one row`() = runTest { + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", markedAt = 1_000L)) + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", markedAt = 9_000L)) + assertEquals(listOf("aa"), dao.observeIds().first()) + } + + @Test + fun `delete removes only the named chat`() = runTest { + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatMarkedUnreadEntity("bb", 1_000L)) + dao.delete("bb") + assertEquals(setOf("aa"), dao.observeIds().first().toSet()) + } + + @Test + fun `delete by set removes only the named chats`() = runTest { + listOf("aa", "bb", "cc").forEach { dao.insertIfAbsent(ChatMarkedUnreadEntity(it, 1_000L)) } + dao.deleteAll(listOf("aa", "cc")) + assertEquals(setOf("bb"), dao.observeIds().first().toSet()) + } + + @Test + fun `deleteAll empties the table`() = runTest { + listOf("aa", "bb").forEach { dao.insertIfAbsent(ChatMarkedUnreadEntity(it, 1_000L)) } + dao.deleteAll() + assertEquals(emptyList(), dao.observeIds().first()) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index d71a84f82a..f26e74d839 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -19,6 +19,7 @@ import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters import com.flipcash.app.persistence.dao.BlockedUserDao import com.flipcash.app.persistence.dao.ChatArchiveDao +import com.flipcash.app.persistence.dao.ChatMarkedUnreadDao import com.flipcash.app.persistence.dao.ChatDraftDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao @@ -34,6 +35,7 @@ import com.flipcash.app.persistence.dao.TokenDao import com.flipcash.app.persistence.dao.UserProfileDao import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.app.persistence.entities.ChatArchiveEntity +import com.flipcash.app.persistence.entities.ChatMarkedUnreadEntity import com.flipcash.app.persistence.entities.ChatDraftEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity @@ -70,6 +72,7 @@ import kotlin.jvm.Synchronized ChatMemberEntity::class, ChatDraftEntity::class, ChatArchiveEntity::class, + ChatMarkedUnreadEntity::class, BlockedUserEntity::class, UserProfileEntity::class, LinkPreviewEntity::class, @@ -145,8 +148,11 @@ import kotlin.jvm.Synchronized // AutoMigration for the same reason as payment_requests. AutoMigration(from = 49, to = 50), // group_key_envelopes table AutoMigration(from = 50, to = 51, spec = FlipcashDatabase.Migration50To51::class), // chat_metadata.can_pin (default 0) + // The chat_marked_unread table, an AutoMigration for the same reason as chat_archive: a + // mark cannot be re-fetched until the server stores it. + AutoMigration(from = 51, to = 52), // chat_marked_unread table ], - version = 51, + version = 52, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) @ConstructedBy(FlipcashDatabaseConstructor::class) @@ -161,6 +167,8 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMemberDao(): ChatMemberDao abstract fun chatDraftDao(): ChatDraftDao abstract fun chatArchiveDao(): ChatArchiveDao + + abstract fun chatMarkedUnreadDao(): ChatMarkedUnreadDao abstract fun blockedUserDao(): BlockedUserDao abstract fun userProfileDao(): UserProfileDao abstract fun linkPreviewDao(): LinkPreviewDao diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt new file mode 100644 index 0000000000..91c6482a3d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.flipcash.app.persistence.entities.ChatMarkedUnreadEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatMarkedUnreadDao { + + /** IGNORE, not REPLACE: marking a marked chat is a no-op and keeps its timestamp. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(mark: ChatMarkedUnreadEntity) + + @Query("SELECT chat_id_hex FROM chat_marked_unread") + fun observeIds(): Flow> + + @Query("DELETE FROM chat_marked_unread WHERE chat_id_hex = :chatIdHex") + suspend fun delete(chatIdHex: String) + + @Query("DELETE FROM chat_marked_unread WHERE chat_id_hex IN (:chatIdHexes)") + suspend fun deleteAll(chatIdHexes: List) + + @Query("DELETE FROM chat_marked_unread") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt new file mode 100644 index 0000000000..a274b64862 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt @@ -0,0 +1,23 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A chat the viewer marked as unread. Presence of the row is the whole state. + * + * Its own table for the same reason as [ChatArchiveEntity]: `chat_metadata` is rewritten by every + * feed sync, and the mark is client-owned until the server stores it. + * + * A row is written by Mark as Unread and removed by Mark as Read, Read All, opening the chat, + * leaving the group, or account erasure. A new message in the chat leaves it alone. + */ +@Entity(tableName = "chat_marked_unread") +data class ChatMarkedUnreadEntity( + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "marked_at") + val markedAt: Long, +) diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt new file mode 100644 index 0000000000..224d09be70 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatMarkedUnreadEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `chat_marked_unread` table, keyed by chat id hex. + * + * Like [ChatArchiveDataSource], no database means nothing marked, and [observeIds] then emits an + * empty set rather than nothing: it is combined into the feed, which would otherwise wait on it. + */ +@Singleton +class ChatMarkedUnreadDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeIds(): Flow> = + db?.chatMarkedUnreadDao()?.observeIds()?.map { it.toSet() } ?: flowOf(emptySet()) + + suspend fun mark(chatIdHex: String, now: Long) { + db?.chatMarkedUnreadDao()?.insertIfAbsent(ChatMarkedUnreadEntity(chatIdHex, markedAt = now)) + } + + suspend fun clear(chatIdHex: String) { + db?.chatMarkedUnreadDao()?.delete(chatIdHex) + } + + suspend fun clear(chatIdHexes: Collection) { + if (chatIdHexes.isEmpty()) return + db?.chatMarkedUnreadDao()?.deleteAll(chatIdHexes.toList()) + } + + suspend fun clearAll() { + db?.chatMarkedUnreadDao()?.deleteAll() + } +} diff --git a/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index f39724f81f..e35900837c 100644 --- a/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -35,6 +35,8 @@ object Flipcash2ColorSpec { val placeholderText = Color(0xFF4E4E4E) val cashBill = Color(0xFF06450F) val notification = Color(0xFF058AFF) + /** [notification] at 10% over [primary], baked flat: the ground of the Mark as Read/Unread swipe. */ + val notificationSecondary = Color(0xFF172431) val trackColor = Color.White.copy(alpha = 0.07f) val bannerThemed = Color(0xFF252526) val success = Color(0xFF1AC86A) @@ -96,6 +98,7 @@ private val colors = with(Flipcash2ColorSpec) { secondary = secondary, tertiary = BrandAccent, indicator = notification, + indicatorSecondary = notificationSecondary, badgeFocus = BadgeFocus, action = Gray50, onAction = White, diff --git a/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt b/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt new file mode 100644 index 0000000000..9612a0b57d --- /dev/null +++ b/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.theme.internal + +import androidx.compose.ui.graphics.Color +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals + +class Flipcash2ColorSpecTest { + + // The flat token is a derivation, so a change to the blue or the background has to move it too. + // Worked per channel at an exact 10% (as iOS derives its secondaries); Compose's `compositeOver` + // quantises the alpha to 26/255 and lands one step greener. + @Test + fun `notificationSecondary is the notification blue at a tenth over the main background`() { + with(Flipcash2ColorSpec) { + assertEquals(tenthOver(notification, primary), rgb(notificationSecondary)) + } + } + + private fun tenthOver(top: Color, ground: Color): List = + listOf(top.red to ground.red, top.green to ground.green, top.blue to ground.blue) + .map { (t, g) -> ((0.1f * t + 0.9f * g) * 255).roundToInt() } + + private fun rgb(color: Color): List = + listOf(color.red, color.green, color.blue).map { (it * 255).roundToInt() } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index e0399f5028..de0347177b 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -10,6 +10,7 @@ public typealias TrackedEvent = SharedCore.AnalyticsEvent // `Amount` are renamed here so they don't read as SwiftUI's `Button` and `@State`, or as // one of the app's money types. public typealias ChatType = SharedCore.ChatType +public typealias ChatReadSource = SharedCore.ChatReadSource public typealias EventState = SharedCore.State public typealias AddMoneySource = SharedCore.AddMoneySource public typealias AddMoneyMethod = SharedCore.AddMoneyMethod diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 8dc934fb50..08d426a977 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -52,6 +52,32 @@ app has no value for it. | State | list | no | [State](#state) | | Error | text | yes | | +### Chat Marked Unread + +The viewer marked a chat as unread from the chat list. Stored on the device only; nothing is sent to the server. Select mode sends one per selected chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Source | list | no | [ChatReadSource](#chatreadsource) | + +### Chat Marked Read + +The viewer marked a chat as read from the chat list. Select mode sends one per selected chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Source | list | no | [ChatReadSource](#chatreadsource) | + +### Chats Read All + +The viewer confirmed Read All on the chat list. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Count | count | no | | + ## Group Group chats. `Member Count` is the member count before the action, from what the screen already holds. `Gate Mint` is left out when the group has no token gate. `Error` is the RPC's result name as the proto spells it, such as RulesNotSatisfied, or Network for a transport failure. @@ -879,6 +905,16 @@ The `Duration` property on Chat Muted: the mute option picked. Both apps offer t | 1 Week | `ONE_WEEK` | | | Always | `ALWAYS` | | +### ChatReadSource + +The `Source` property on Chat Marked Unread and Chat Marked Read: where on the chat list the action was taken. + +| Value sent | Name in code | Drift | +|---|---|---| +| Swipe | `SWIPE` | | +| Menu | `MENU` | Android doesn't send this source: a long press on Android enters select mode, so its actions go out as Selection. | +| Selection | `SELECTION` | | + ### SilenceDuration The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always. diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 84e2c4add2..bad5049beb 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -141,6 +141,13 @@ EIGHT_HOURS = "8 Hours" ONE_WEEK = "1 Week" ALWAYS = "Always" +[lists.ChatReadSource] +description = "The `Source` property on Chat Marked Unread and Chat Marked Read: where on the chat list the action was taken." +[lists.ChatReadSource.values] +SWIPE = "Swipe" +MENU = { value = "Menu", drift = "Android doesn't send this source: a long press on Android enters select mode, so its actions go out as Selection." } +SELECTION = "Selection" + [lists.SilenceDuration] description = "The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always." [lists.SilenceDuration.values] @@ -367,6 +374,35 @@ properties = [ { name = "Error", type = "text", optional = true }, ] +[[event]] +domain = "Chat" +name = "Chat Marked Unread" +builder = "markedUnread" +description = "The viewer marked a chat as unread from the chat list. Stored on the device only; nothing is sent to the server. Select mode sends one per selected chat." +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Source", type = "ChatReadSource" }, +] + +[[event]] +domain = "Chat" +name = "Chat Marked Read" +builder = "markedRead" +description = "The viewer marked a chat as read from the chat list. Select mode sends one per selected chat." +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Source", type = "ChatReadSource" }, +] + +[[event]] +domain = "Chat" +name = "Chats Read All" +builder = "readAll" +description = "The viewer confirmed Read All on the chat list." +properties = [ + { name = "Count", type = "count" }, +] + # Group [[event]] diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt index 67336b0087..765565e56d 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt @@ -108,4 +108,43 @@ class ChatEventsTest { ChatEvents.unmuted(ChatType.GROUP, State.SUCCESS, null), ) } + + @Test + fun chatMarkedUnreadCarriesTypeAndSource() { + assertEquals( + AnalyticsEvent( + "Chat Marked Unread", + mapOf("Chat Type" to PropertyValue.Text("Group"), "Source" to PropertyValue.Text("Swipe")), + ), + ChatEvents.markedUnread(ChatType.GROUP, ChatReadSource.SWIPE), + ) + } + + @Test + fun chatMarkedReadCarriesTypeAndSource() { + assertEquals( + AnalyticsEvent( + "Chat Marked Read", + mapOf("Chat Type" to PropertyValue.Text("Tip"), "Source" to PropertyValue.Text("Selection")), + ), + ChatEvents.markedRead(ChatType.TIP, ChatReadSource.SELECTION), + ) + } + + @Test + fun chatReadSourcesKeepTheirWireStrings() { + assertEquals( + listOf("Swipe", "Menu", "Selection"), + listOf(ChatReadSource.SWIPE, ChatReadSource.MENU, ChatReadSource.SELECTION) + .map { (ChatEvents.markedRead(ChatType.GROUP, it).properties.getValue("Source") as PropertyValue.Text).value }, + ) + } + + @Test + fun chatsReadAllCarriesTheCount() { + assertEquals( + AnalyticsEvent("Chats Read All", mapOf("Count" to PropertyValue.Number(2.0))), + ChatEvents.readAll(2), + ) + } } diff --git a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt new file mode 100644 index 0000000000..09fe9e4b63 --- /dev/null +++ b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt @@ -0,0 +1,301 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import androidx.compose.ui.graphics.toPixelMap +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode +import kotlin.math.abs +import kotlin.test.assertEquals + +/** + * The leading side added to [SwipeActionRow]: a full swipe right fires the leading action once + * and, for an action that resets, sends the row home; a row with only trailing actions does not + * move on a swipe right. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SwipeActionRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var leadingFired = 0 + private var trailingFired = 0 + private var labelWidth = 0.dp + private var circleRevealWidth = 0.dp + private var pillRevealWidth = 0.dp + + private fun show(withLeading: Boolean, leadingLabel: String? = null) { + composeTestRule.setContent { + DesignSystem { + // What the row should open to: the label's width (when wider than the circle) + // plus the inset on each side, measured with the style the row draws it in. + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val style = CodeTheme.typography.caption + labelWidth = leadingLabel?.let { + with(density) { measurer.measure(it, style, maxLines = 1).size.width.toDp() } + } ?: 0.dp + circleRevealWidth = CodeTheme.dimens.staticGrid.x10 + CodeTheme.dimens.inset * 2 + // A labelled action is a pill 1.4 times the circle's width. + pillRevealWidth = CodeTheme.dimens.staticGrid.x10 * 1.4f + CodeTheme.dimens.inset * 2 + Box(Modifier.width(360.dp)) { + SwipeActionRow( + actions = listOf( + SwipeAction(background = Color.Gray, onTriggered = { trailingFired++ }) {}, + ), + leadingActions = if (withLeading) { + listOf( + SwipeAction( + background = Color.Blue, + onTriggered = { leadingFired++ }, + resetOnDismiss = true, + label = leadingLabel, + ) { + Box( + Modifier.fillMaxSize().semantics { + contentDescription = LEADING_DESCRIPTION + } + ) + } + ) + } else { + emptyList() + }, + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + } + } + + private fun rowLeft() = composeTestRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().left + + @Test + fun `a full leading swipe fires once and the row comes home`() { + show(withLeading = true) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeRight(startX = left + 1f, endX = right) } + composeTestRule.waitForIdle() + + assertEquals(1, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + + // The device path: a short drag leaves the row revealed, then a fast full swipe fires it. + @Test + fun `a full leading swipe from the revealed row fires once and the row comes home`() { + show(withLeading = true) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 60.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + assert(rowLeft() > 0.dp) { "the short drag should leave the row revealed" } + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + } + + // The device path again, on a labelled action: a slow drag opens the row wide enough for the + // label, the circle keeps its content description and the label adds no node of its own, a + // tap on the revealed action fires it, and a full swipe still fires and comes home. + @Test + fun `a labelled leading action opens to fit its label and still fires`() { + show(withLeading = true, leadingLabel = LABEL) + assert(labelWidth > 70.dp) { "the test label ($labelWidth) should be wider than the pill" } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + // Past half the reveal, short of a full swipe. + swipeRight(startX = left + 1f, endX = left + labelWidth.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + val revealed = rowLeft() + assert(revealed > circleRevealWidth) { "revealed $revealed should be wider than the circle-only $circleRevealWidth" } + assert(revealed >= labelWidth) { "revealed $revealed should fit the label $labelWidth" } + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithContentDescription(LEADING_DESCRIPTION).assertExists() + composeTestRule.onAllNodesWithText(LABEL).assertCountEquals(0) + + composeTestRule.onNodeWithContentDescription(LEADING_DESCRIPTION).performTouchInput { click() } + composeTestRule.waitForIdle() + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 60.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + + assertEquals(2, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + + @Test + fun `a row with only trailing actions does not move on a swipe right`() { + show(withLeading = false) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeRight(startX = left + 1f, endX = right) } + composeTestRule.waitForIdle() + + assertEquals(0, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + + // The chat list's leading title is short, so a labelled leading action opens to the pill's + // width (1.4 times the circle, as iOS draws a titled action) and no wider, and its swipe still + // fires on the real gesture sequence. + @Test + fun `a short label opens the reveal to the pill width`() { + show(withLeading = true, leadingLabel = SHORT_LABEL) + assert(labelWidth <= 70.dp) { "the short label ($labelWidth) should fit inside the pill" } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 80.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + val revealed = rowLeft() + assert(revealed > circleRevealWidth) { "revealed $revealed should be wider than the circle-only $circleRevealWidth" } + assertEquals(pillRevealWidth.value, revealed.value, 1f) + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + } + + // A slow drag past the reveal toward a full swipe: the inner action fades with the offset, from + // full at the reveal to nearly gone at the point a release would commit, and is back to full + // once the row is released short of that point and settles revealed. + @Test + fun `the inner trailing action fades as a full swipe commits and returns on release`() { + var revealWidth = 0.dp + composeTestRule.setContent { + DesignSystem { + revealWidth = CodeTheme.dimens.staticGrid.x10 * 2 + CodeTheme.dimens.inset * 3 + Box(Modifier.fillMaxWidth().background(Color.Black)) { + SwipeActionRow( + actions = listOf( + SwipeAction(background = Color.Red, onTriggered = {}) { + Box(Modifier.fillMaxSize().semantics { contentDescription = INNER }) + }, + SwipeAction(background = Color.Gray, onTriggered = { trailingFired++ }) {}, + ), + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + } + // Releasing past halfway from the reveal to the row's width commits the full swipe. + val rowWidth = composeTestRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().let { it.right - it.left } + val threshold = revealWidth + (rowWidth - revealWidth) / 2 + fun innerRed(): Float { + val pixels = composeTestRule.onNodeWithContentDescription(INNER).captureToImage().toPixelMap() + return pixels[pixels.width / 2, pixels.height / 2].red + } + fun opened() = -rowLeft() + // A slow drag in small steps, steered by where the row actually is: injected moves can + // land a frame late, so each step is sized from the measured offset. The first step also + // covers the touch slop. Moved by deltas because the node's bounds move with the row. + var started = false + fun openTo(open: Dp) { + repeat(200) { + val remaining = open - opened() + if (abs(remaining.value) < 1f) return + val step = remaining.coerceIn(-4.dp, 4.dp) + composeTestRule.onNodeWithTag(ROW).performTouchInput { + val slop = if (started) 0f else viewConfiguration.touchSlop + moveBy(androidx.compose.ui.geometry.Offset(-(slop + step.toPx()), 0f)) + } + started = true + composeTestRule.waitForIdle() + } + } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { down(androidx.compose.ui.geometry.Offset(right - 1f, centerY)) } + openTo(revealWidth) + composeTestRule.waitForIdle() + assertEquals(revealWidth.value, opened().value, 2f) + assert(innerRed() > 0.95f) { "at the reveal the inner action should be opaque, red ${innerRed()}" } + + val midway = revealWidth + (threshold - revealWidth) / 2 + openTo(midway) + composeTestRule.waitForIdle() + // Linear in how far past the reveal the row is, measured on the row itself. + val expected = 1f - (opened() - revealWidth) / (threshold - revealWidth) + assert(expected in 0.2f..0.8f) { "midway drag opened ${opened()}, expected alpha $expected" } + assertEquals(expected, innerRed(), 0.05f) + + openTo(threshold - 2.dp) + composeTestRule.waitForIdle() + assertEquals((threshold - 2.dp).value, opened().value, 2f) + assert(innerRed() < 0.1f) { "near the threshold the inner action should be nearly gone, red ${innerRed()}" } + + // Back short of the threshold and let go: the row settles revealed, the action opaque again. + openTo(revealWidth + 10.dp) + composeTestRule.onNodeWithTag(ROW).performTouchInput { up() } + composeTestRule.waitForIdle() + assertEquals(revealWidth.value, opened().value, 2f) + assert(innerRed() > 0.95f) { "after release the inner action should be opaque, red ${innerRed()}" } + assertEquals(0, trailingFired) + } + + private companion object { + const val ROW = "row" + // Wider than the circle, as the Members row's titles are. + const val LABEL = "Lift Restriction" + // The chat list's leading title: no wider than the circle. + const val SHORT_LABEL = "Unread" + const val LEADING_DESCRIPTION = "Mark chat as unread" + const val INNER = "Inner action" + } +} diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 4a1ac308b1..5f128e36c7 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -1,13 +1,16 @@ package com.getcode.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,10 +24,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -40,21 +44,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import org.jetbrains.compose.resources.painterResource +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import com.getcode.theme.White50 -import androidx.compose.foundation.clickable -import kotlin.math.abs -import kotlinx.coroutines.launch import com.getcode.util.resources.Res import com.getcode.util.resources.ic_delete +import kotlin.math.abs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import org.jetbrains.compose.resources.painterResource /** * Describes a single action revealed behind the row content on swipe. @@ -65,6 +80,10 @@ import com.getcode.util.resources.ic_delete * @param resetOnDismiss When true, a full swipe triggers [onTriggered] and animates * the row back instead of settling at the dismissed position. Callbacks used with * this flag should be idempotent (e.g. clipboard writes). + * @param label Optional title drawn under the circle in small secondary text. With a label the + * action is a circle with its title under it, and its slot widens to fit the title; without + * one it fills the row's height as before. The label is not announced: the icon's content + * description already names the action. * @param content Composable content displayed inside the action button (typically an [Icon]). */ @Stable @@ -72,10 +91,13 @@ class SwipeAction( val background: Color, val onTriggered: () -> Unit, val resetOnDismiss: Boolean = false, + val label: String? = null, val content: @Composable () -> Unit, ) -private enum class SwipeState { Settled, Revealed, Dismissed } +// Revealed and Dismissed are the trailing side (content moved left); the Start pair is the leading +// side (content moved right), present only when the row has leading actions. +private enum class SwipeState { Settled, Revealed, Dismissed, RevealedStart, DismissedStart } /** * Keeps at most one [SwipeActionRow] open among the rows given the same group: starting a swipe on @@ -135,6 +157,11 @@ fun SwipeActionRow( * rightmost position, grows from a circle into a rounded rectangle as the swipe * progresses, and is automatically triggered on a full swipe (dismiss). All other * actions remain fixed-size circles and are click-only. + * + * [leadingActions] mirror that on the other side: swiping right reveals them, the **last** one + * sits leftmost, grows on the swipe and fires on a full swipe, honouring its own + * [SwipeAction.resetOnDismiss]. A full leading swipe also plays the gesture haptic. Without + * leading actions the row swipes left only, exactly as before. */ @Composable fun SwipeActionRow( @@ -143,9 +170,10 @@ fun SwipeActionRow( stateKey: Any? = null, initiallyRevealed: Boolean = false, revealGroup: SwipeRevealGroup? = null, + leadingActions: List = emptyList(), content: @Composable () -> Unit, ) { - if (actions.isEmpty()) { + if (actions.isEmpty() && leadingActions.isEmpty()) { Box(modifier) { content() } return } @@ -155,17 +183,35 @@ fun SwipeActionRow( val actionPadding = CodeTheme.dimens.inset val minActionSize = CodeTheme.dimens.staticGrid.x10 - // Each action slot = minSize + padding on each side. - // Total reveal = N slots + one extra padding on the outer edge. - val totalRevealWidth = (minActionSize + actionPadding) * actions.size + actionPadding - val totalRevealWidthPx = with(density) { totalRevealWidth.toPx() } + // Each action slot = its width + padding on each side, where the width is the circle's unless + // a label is wider. Total reveal = N slots + one extra padding on the outer edge. + val labelStyle = CodeTheme.typography.caption + val textMeasurer = rememberTextMeasurer() + val trailingLabelWidths = actions.map { action -> + action.label?.let { textMeasurer.measure(it, labelStyle, maxLines = 1).size.width } + } + val leadingLabelWidths = leadingActions.map { action -> + action.label?.let { textMeasurer.measure(it, labelStyle, maxLines = 1).size.width } + } + val totalRevealWidthPx = with(density) { + revealWidthPx(trailingLabelWidths, minActionSize.toPx(), actionPadding.toPx()) + } - val dismissAction = actions.last() - val currentDismissCallback by rememberUpdatedState(dismissAction.onTriggered) - val currentDismissResets by rememberUpdatedState(dismissAction.resetOnDismiss) + val dismissAction = actions.lastOrNull() + val currentDismissCallback by rememberUpdatedState(dismissAction?.onTriggered ?: {}) + val currentDismissResets by rememberUpdatedState(dismissAction?.resetOnDismiss ?: false) var pendingResetCallback by remember(stateKey) { mutableStateOf(false) } + val leadingRevealWidthPx = with(density) { + revealWidthPx(leadingLabelWidths, minActionSize.toPx(), actionPadding.toPx()) + } + val leadingDismissAction = leadingActions.lastOrNull() + val currentLeadingCallback by rememberUpdatedState(leadingDismissAction?.onTriggered ?: {}) + val currentLeadingResets by rememberUpdatedState(leadingDismissAction?.resetOnDismiss ?: false) + var pendingLeadingReset by remember(stateKey) { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + val initialState = if (initiallyRevealed) SwipeState.Revealed else SwipeState.Settled val state = remember(stateKey) { AnchoredDraggableState( @@ -174,6 +220,9 @@ fun SwipeActionRow( if (newValue == SwipeState.Dismissed && currentDismissResets) { pendingResetCallback = true false // reject settle, system animates back + } else if (newValue == SwipeState.DismissedStart && currentLeadingResets) { + pendingLeadingReset = true + false } else { true } @@ -201,9 +250,38 @@ fun SwipeActionRow( // the next swipe to write again without relaunching anything. LaunchedEffect(dragInteractions) { dragInteractions.interactions.collect { interaction -> - if (interaction is DragInteraction.Start) pendingResetCallback = false + if (interaction is DragInteraction.Start) { + pendingResetCallback = false + pendingLeadingReset = false + } + } + } + // The leading side's version of the reset above. The threshold is confirmed while the finger is + // still down, so the callback and haptic fire then; sending the row home at that point would + // cancel the drag and leave the settle that follows on RevealedStart. Instead the row goes home + // once nothing is dragging or animating it, which also covers a swipe begun from the revealed row. + LaunchedEffect(pendingLeadingReset) { + if (pendingLeadingReset) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + currentLeadingCallback() } } + val isDragged by dragInteractions.collectIsDraggedAsState() + // The drag's own settle can start just after this does and interrupt it, landing on + // RevealedStart, so each attempt re-reads the state and waits for that settle to finish. + LaunchedEffect(state) { + while (true) { + snapshotFlow { + pendingLeadingReset && !isDragged && !state.isAnimationRunning && state.offset > 0f + }.first { it } + try { + state.animateTo(SwipeState.Settled) + } catch (e: CancellationException) { + currentCoroutineContext().ensureActive() + } + } + } + if (revealGroup != null) { val fallbackKey = remember { Any() } val groupKey = stateKey ?: fallbackKey @@ -231,14 +309,26 @@ fun SwipeActionRow( if (state.currentValue == SwipeState.Dismissed) { currentDismissCallback() } + if (state.currentValue == SwipeState.DismissedStart) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + currentLeadingCallback() + } } - LaunchedEffect(rowWidthPx, totalRevealWidthPx) { + val hasTrailing = actions.isNotEmpty() + val hasLeading = leadingActions.isNotEmpty() + LaunchedEffect(rowWidthPx, totalRevealWidthPx, leadingRevealWidthPx, hasTrailing, hasLeading) { if (rowWidthPx > 0f) { state.updateAnchors(DraggableAnchors { SwipeState.Settled at 0f - SwipeState.Revealed at -totalRevealWidthPx - SwipeState.Dismissed at -rowWidthPx + if (hasTrailing) { + SwipeState.Revealed at -totalRevealWidthPx + SwipeState.Dismissed at -rowWidthPx + } + if (hasLeading) { + SwipeState.RevealedStart at leadingRevealWidthPx + SwipeState.DismissedStart at rowWidthPx + } }) } } @@ -253,17 +343,17 @@ fun SwipeActionRow( Layout( content = { actions.forEachIndexed { index, action -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(action.background) - .clickable { - action.onTriggered() - scope.launch { state.animateTo(SwipeState.Settled) } - }, - contentAlignment = Alignment.Center, - ) { - action.content() + // The inner actions fade as a full swipe commits; the outermost grows instead. + val fade = if (index < actions.lastIndex) { + Modifier.graphicsLayer { + alpha = innerActionAlpha(-state.offset, totalRevealWidthPx, rowWidthPx) + } + } else { + Modifier + } + SwipeActionSlot(action, labelStyle, minActionSize, fade) { + action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } } } }, @@ -275,30 +365,80 @@ fun SwipeActionRow( val n = measurables.size val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) - // Non-dismiss actions each occupy a fixed slot: padding + minSize. - // The dismiss action (last) gets the remaining width and grows on full swipe. - val fixedSlotWidth = minPx + padPx - val nonDismissTotal = (n - 1) * fixedSlotWidth + // Non-dismiss actions each occupy a fixed slot: padding + their width (the circle's, + // or the label's when wider). The dismiss action (last) gets the remaining width and + // grows on full swipe. + val slots = trailingLabelWidths.map { slotWidthPx(it, minPx) } + val nonDismissTotal = slots.dropLast(1).sumOf { it + padPx } val dismissAvailable = (absOffset - padPx - nonDismissTotal).coerceAtLeast(0f) - val dismissW = (dismissAvailable.toInt() - padPx).coerceAtLeast(minPx) + val dismissW = (dismissAvailable.toInt() - padPx).coerceAtLeast(slots.lastOrNull() ?: minPx) val placeables = measurables.mapIndexed { index, measurable -> - val w = if (index == n - 1) dismissW else minPx - measurable.measure(Constraints.fixed(w, actionH)) + val w = if (index == n - 1) dismissW else slots[index] + // A labelled action lays out its circle and title in the row's full height. + val h = if (trailingLabelWidths[index] != null) constraints.maxHeight else actionH + measurable.measure(Constraints.fixed(w, h)) } layout(constraints.maxWidth, constraints.maxHeight) { - val yCenter = (constraints.maxHeight - actionH) / 2 + // Hidden, and so not tappable, while the leading side is open. + if (state.offset > 0f || n == 0) return@layout var x = constraints.maxWidth - padPx // Dismiss action (rightmost) x -= placeables.last().width - placeables.last().place(x, yCenter) + placeables.last().place(x, (constraints.maxHeight - placeables.last().height) / 2) // Remaining actions, right to left for (i in n - 2 downTo 0) { x -= padPx + placeables[i].width - placeables[i].place(x, yCenter) + placeables[i].place(x, (constraints.maxHeight - placeables[i].height) / 2) + } + } + } + + if (hasLeading) { + Layout( + content = { + leadingActions.forEachIndexed { index, action -> + val fade = if (index < leadingActions.lastIndex) { + Modifier.graphicsLayer { + alpha = innerActionAlpha(state.offset, leadingRevealWidthPx, rowWidthPx) + } + } else { + Modifier + } + SwipeActionSlot(action, labelStyle, minActionSize, fade) { + action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } + } + } + }, + modifier = Modifier.matchParentSize(), + ) { measurables, constraints -> + // The trailing measure policy, mirrored: the last action is outermost (leftmost). + val offset = state.offset.coerceAtLeast(0f) + val padPx = actionPaddingDp.roundToPx() + val minPx = minActionSize.roundToPx() + val n = measurables.size + val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) + val slots = leadingLabelWidths.map { slotWidthPx(it, minPx) } + val outerAvailable = (offset - padPx - slots.dropLast(1).sumOf { it + padPx }).coerceAtLeast(0f) + val outerW = (outerAvailable.toInt() - padPx).coerceAtLeast(slots.last()) + val placeables = measurables.mapIndexed { index, measurable -> + val h = if (leadingLabelWidths[index] != null) constraints.maxHeight else actionH + measurable.measure(Constraints.fixed(if (index == n - 1) outerW else slots[index], h)) + } + layout(constraints.maxWidth, constraints.maxHeight) { + if (offset <= 0f) return@layout + var x = padPx + placeables.last().place(x, (constraints.maxHeight - placeables.last().height) / 2) + x += placeables.last().width + for (i in n - 2 downTo 0) { + x += padPx + placeables[i].place(x, (constraints.maxHeight - placeables[i].height) / 2) + x += placeables[i].width + } } } } @@ -318,6 +458,105 @@ fun SwipeActionRow( } } +/** + * A labelled action draws its background as a pill: the circle's height and [PillWidthRatio] times + * its width, matching the titled swipe actions iOS draws. + */ +private const val PillWidthRatio = 1.4f + +private fun pillWidthPx(circlePx: Float): Float = circlePx * PillWidthRatio + +/** + * An action's slot width: the circle's when unlabelled; when labelled, the pill's, or the label's + * when the label is wider. + */ +private fun slotWidthPx(labelWidthPx: Int?, circlePx: Int): Int = + if (labelWidthPx == null) circlePx else maxOf(pillWidthPx(circlePx.toFloat()).roundToInt(), labelWidthPx) + +/** + * The alpha of an action that is not the outermost, for a side open by [openPx]: 1 up to the + * reveal width, falling linearly to 0 at the point past which releasing commits the full swipe + * (halfway from the reveal to the row's width, the drag's default positional threshold). + */ +internal fun innerActionAlpha(openPx: Float, revealPx: Float, rowWidthPx: Float): Float { + val span = (rowWidthPx - revealPx) / 2f + if (span <= 0f) return 1f + return 1f - ((openPx - revealPx) / span).coerceIn(0f, 1f) +} + +/** The reveal width for [labelWidths] (one per action, null when unlabelled), as a float like the anchors. */ +private fun revealWidthPx(labelWidths: List, circlePx: Float, padPx: Float): Float = + labelWidths.sumOf { slotWidthPx(it, circlePx.roundToInt()).toDouble() }.toFloat() + + padPx * (labelWidths.size + 1) + +/** + * One action behind the row. Unlabelled, it is the circle (or, stretched by a full swipe, the pill) + * the row measures it to. Labelled, it is a pill the circle's height and [PillWidthRatio] times as + * wide, with the label centred under it and the pair centred in the row's height; when the + * outermost action is stretched by a full swipe the pill widens by the same amount and the label + * stays under it. The whole slot, label included, takes the tap; the ripple stays on the pill. + */ +@Composable +private fun SwipeActionSlot( + action: SwipeAction, + labelStyle: TextStyle, + circleSize: Dp, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val label = action.label + if (label == null) { + Box( + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + action.content() + } + return + } + val interactions = remember { MutableInteractionSource() } + val gap = CodeTheme.dimens.grid.x1 + Layout( + content = { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .indication(interactions, ripple()), + contentAlignment = Alignment.Center, + ) { + action.content() + } + Text( + text = label, + style = labelStyle, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + softWrap = false, + modifier = Modifier.clearAndSetSemantics {}, + ) + }, + modifier = modifier.clickable(interactionSource = interactions, indication = null, onClick = onClick), + ) { measurables, constraints -> + val circlePx = circleSize.roundToPx() + val gapPx = gap.roundToPx() + val text = measurables[1].measure(Constraints()) + val slot = slotWidthPx(text.width, circlePx) + val pillPx = pillWidthPx(circlePx.toFloat()).roundToInt() + val circle = measurables[0].measure( + Constraints.fixed(pillPx + (constraints.maxWidth - slot).coerceAtLeast(0), circlePx) + ) + layout(constraints.maxWidth, constraints.maxHeight) { + val top = (constraints.maxHeight - circle.height - gapPx - text.height) / 2 + circle.place((constraints.maxWidth - circle.width) / 2, top) + text.place((constraints.maxWidth - text.width) / 2, top + circle.height + gapPx) + } + } +} + @Preview @Composable private fun SwipeActionRowPreview() { diff --git a/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml new file mode 100644 index 0000000000..31fc1a8cc1 --- /dev/null +++ b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml new file mode 100644 index 0000000000..7054405d32 --- /dev/null +++ b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt b/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt deleted file mode 100644 index 9c086f473b..0000000000 --- a/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.getcode.util.resources.icons - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp - -// Speaker with no sound waves and a diagonal slash, matching iOS's SF Symbol `speaker.slash`. -private var _SpeakerSlash: ImageVector? = null -val SpeakerSlash: ImageVector - get() { - if (_SpeakerSlash != null) { - return _SpeakerSlash!! - } - _SpeakerSlash = ImageVector.Builder( - name = "SpeakerSlash", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f - ).apply { - path( - fill = null, - fillAlpha = 1.0f, - stroke = SolidColor(Color(0xFFFFFFFF)), - strokeAlpha = 1.0f, - strokeLineWidth = 1.8f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero - ) { - moveTo(6f, 9.5f) - horizontalLineTo(9f) - lineTo(14f, 5f) - verticalLineTo(19f) - lineTo(9f, 14.5f) - horizontalLineTo(6f) - close() - moveTo(4f, 4f) - lineTo(20f, 20f) - } - }.build() - return _SpeakerSlash!! - } diff --git a/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt index bc35d1646c..a312d1806e 100644 --- a/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.compositeOver import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color @@ -166,6 +167,12 @@ class ColorScheme( accessKey: GradientSpec, contactAvatar: GradientSpec, chat: ChatColors, + /** + * The ground [indicator] sits on when it labels an action rather than counting something, e.g. + * the chat row's Mark as Read/Unread swipe: the indicator at a tenth of its strength over + * [background], baked flat. Only true against the main background; it is not a general wash. + */ + indicatorSecondary: Color = indicator.copy(alpha = 0.1f).compositeOver(background), ) { var brand by mutableStateOf(brand) private set @@ -221,6 +228,8 @@ class ColorScheme( private set var badgeFocus by mutableStateOf(badgeFocus) private set + var indicatorSecondary by mutableStateOf(indicatorSecondary) + private set var action by mutableStateOf(action) private set var onAction by mutableStateOf(onAction) @@ -291,6 +300,7 @@ class ColorScheme( tertiary = other.tertiary indicator = other.indicator badgeFocus = other.badgeFocus + indicatorSecondary = other.indicatorSecondary action = other.action onAction = other.onAction border = other.border @@ -359,6 +369,7 @@ class ColorScheme( accessKey = accessKey, contactAvatar = contactAvatar, chat = chat, + indicatorSecondary = indicatorSecondary, ) }