diff --git a/.claude/state/handoff.md b/.claude/state/handoff.md new file mode 100644 index 0000000000..b32687f62f --- /dev/null +++ b/.claude/state/handoff.md @@ -0,0 +1,27 @@ +# Handoff — group-chat Edit UI + +**Done and shipped.** [code-android-app#1525](https://github.com/code-payments/code-android-app/pull/1525), +stacked on #1524 (`feat/chat-roster-edit-rpcs`). Worktree `.claude/worktrees/edit-chat-ui`, +branch `feat/edit-chat-ui`, commit `e0479e2e9`. + +## Verified green +`:services:flipcash:compileDebugKotlin`, `:apps:flipcash:core:`, `:apps:flipcash:shared:chat:`, +messenger + tipping `testDebugUnitTest`, and `:apps:flipcash:app:assembleDebug` (the last one +proves the two new `@HiltViewModel`s wire up). 12 new tests, all ran. + +## Key decisions +- `canEdit` gate is passed in, never derived. `groupProfileOverflowItems(canEdit)` returns a list + so no-permission means no overflow button. +- `titleOnly` / `pictureOnly` in `PartialEdits.kt` encode "send only what changed" — EditChat + leaves unset fields alone, so an over-filled request is destructive. +- Title length counted in **code points** (protovalidate max_len semantics), on the trimmed value. +- Echo is idempotent: `TitleChanged`/`PictureChanged` carry absolute values, so persisting the + editChat response and then receiving the stream event writes the same thing. +- `moderationDescription` extracted from `CreateGroupViewModel` to `core/moderation`, unchanged. +- Built Icon + Name only; the other three node rows have no flipcash2 0.11.0 contract. +- Used repo `MenuList`/`ListItem` metrics (24dp/17sp), not the node's 26dp/20sp — flagged in the PR. +- Terminology: "Group", not the node's "Community". + +## Environment gotcha +`google-services.json` is gitignored and absent from fresh worktrees; copy it from the main +checkout (`apps/flipcash/app/`) before any `assembleDebug`. diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 0efc8db555..a4d24633c8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -82,4 +82,31 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data object GroupProfile : ChatStep + + /** + * Node 10187:110373 — what about the group can be changed, as a list of the things that can. + * + * A pushed screen rather than a sheet, unlike [InviteToGroup] and [MuteChat]: those end on the + * choice they present, while this one is a way through to another screen and has to be + * somewhere back can return to. Carries nothing for the same reason [GroupProfile] does not — + * the flow is already open on the group, and every step here reads it off the conversation's + * view model. + * + * The design node lists four rows; only [EditGroupPicture] and [EditGroupName] are built. + * Membership card, description and social links have no field on `EditChatRequest` in + * flipcash2 0.11.0, so there is nothing for them to write. + */ + @Parcelize + @Serializable + data object EditGroup : ChatStep + + /** The group's title, on the same entry screen shape the profile name uses. */ + @Parcelize + @Serializable + data object EditGroupName : ChatStep + + /** The group's picture — pick, upload, then `EditChat` with the READY blob. */ + @Parcelize + @Serializable + data object EditGroupPicture : ChatStep } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt new file mode 100644 index 0000000000..4effe9e89b --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.core.moderation + +import androidx.annotation.StringRes +import com.flipcash.core.R +import com.flipcash.services.models.ModerationResult + +/** + * The app's wording for a moderation refusal, one message per [ModerationResult.FlaggedCategory]. + * + * Extracted from `CreateGroupViewModel` so the group *edit* flow refuses a title in the same words + * group *creation* does. The strings are deliberately the profile-name ones: they are written + * about a rejected piece of user text rather than about a profile, and the caller supplies the + * title that says which piece it was. + * + * [NONE] means the server flagged the content without naming a category, so it gets the generic + * "try a different one" rather than a reason the response never gave. + */ +@StringRes +fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = + when (category) { + ModerationResult.FlaggedCategory.NONE -> + R.string.error_description_imageNotAllowed + + ModerationResult.FlaggedCategory.OTHER -> + R.string.error_description_profileNameNotAllowedFlaggedOther + + ModerationResult.FlaggedCategory.NSFW -> + R.string.error_description_profileNameNotAllowedFlaggedNsfw + + ModerationResult.FlaggedCategory.IMPERSONATION -> + R.string.error_description_profileNameNotAllowedFlaggedImpersonation + + ModerationResult.FlaggedCategory.MISLEADING -> + R.string.error_description_profileNameNotAllowedFlaggedMisleading + + ModerationResult.FlaggedCategory.SPAM -> + R.string.error_description_profileNameNotAllowedFlaggedSpam + } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a3cd5afd1e..52a0a97b6d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1039,6 +1039,41 @@ Something Went Wrong We couldn\'t create this group. Please try again. + + Edit Group + Picture + Name + + Set Picture + Set Name + + + Change Group Name? + Everyone in this group will see it as \"%1$s\" + Change Name + Change Group Picture? + Everyone in this group will see the new picture + Change Picture + + Up to 64 characters + + Can\'t Edit This Group + You don\'t have permission to edit this group + + Something Went Wrong + We couldn\'t save your changes. Please try again. + Invite to Join Group Send Invite Link diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 20760fb700..d14d74190e 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index fb6b7c5f21..1ca0210814 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -28,6 +28,9 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator @@ -105,6 +108,15 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupProfileScreen() } + annotatedEntry { + EditGroupScreen() + } + annotatedEntry { + FlowEditGroupNameScreen() + } + annotatedEntry { + FlowEditGroupPictureScreen() + } } @Composable @@ -308,3 +320,16 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { private fun FlowGroupProfileScreen() { GroupProfileScreen(flowSharedViewModel()) } + +// Both edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way +// the profile above them does, and keep the edit itself in their own nav-entry-scoped view model. +// EditGroupScreen needs neither and is registered directly. +@Composable +private fun FlowEditGroupNameScreen() { + EditGroupNameScreen(flowSharedViewModel()) +} + +@Composable +private fun FlowEditGroupPictureScreen() { + EditGroupPictureScreen(flowSharedViewModel()) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index 9c4c0fc4b4..59946e410f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable @@ -9,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.MenuItem import com.flipcash.features.messenger.R /** @@ -24,6 +26,9 @@ internal sealed interface GroupProfileAction { data object Mute : GroupProfileAction data object Leave : GroupProfileAction data object Report : GroupProfileAction + + /** Opens node 10187:110373's list of what about the group can be changed. */ + data object Edit : GroupProfileAction } /** @@ -68,3 +73,36 @@ internal data object LeaveChat : FullMenuItem() { /** The group's mute row. Shared definition; see [MuteChatItem]. */ internal val MuteChat = MuteChatItem(GroupProfileAction.Mute) + +/** + * The group's edit row, in the profile's top-right overflow rather than in the list below it. + * + * A pencil, the same glyph the message overflow uses for editing a message — one verb, one icon. + * Not `ic_group_3`, which names a group rather than the act of changing one. + * + * Node 10187:110373 is the screen this opens; the overflow it hangs from is the message + * long-press menu's shape reused (see `ChatTopBar.MessageOverflow`). + */ +internal data object EditGroup : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Edit) + + override val name: String + @Composable get() = stringResource(R.string.action_edit) + + override val action: GroupProfileAction = GroupProfileAction.Edit +} + +/** + * What the profile's top-right overflow holds, for a viewer the server says may edit. + * + * [canEdit] comes from `ViewerState.Permissions` and is server-computed. It is passed in rather + * than derived here on purpose: membership, being the creator, and the chat's kind all *look* like + * they answer the question and none of them is the answer the server gives, so a local guess would + * offer an Edit that `EditChat` then refuses with `DENIED`. + * + * Returns a list so the overflow has nothing to show — and so draws nothing — when the answer is + * no. A single-item menu is still a menu; an empty one is not a button. + */ +internal fun groupProfileOverflowItems(canEdit: Boolean): List> = + if (canEdit) listOf(EditGroup) else emptyList() 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 2f99654c8c..b2c28067a9 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 @@ -2,20 +2,33 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep @@ -32,6 +45,8 @@ import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance @@ -64,7 +79,26 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { CodeScaffold( topBar = { - AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) + AppBarWithTitle( + onBackIconClicked = { flowNavigator.back() }, + endContent = { + GroupProfileOverflow( + // Server-computed, and absent entirely for a non-member, so an + // unresolved viewer state reads as "may not edit" rather than as a + // permission to be re-derived here. + canEdit = state.viewerState?.permissions?.canEdit == true, + onAction = { action -> + when (action) { + GroupProfileAction.Edit -> + flowNavigator.navigateTo(ChatStep.EditGroup) + // The overflow carries the edit row alone; the rest of + // [GroupProfileAction] is the list's. + else -> Unit + } + }, + ) + }, + ) }, ) { innerPadding -> MenuList( @@ -118,12 +152,94 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { AppRoute.Messaging.Report(ReportSubject.Chat(chatId)) ) } + // Edit is an overflow row, not a body row, so this arm is unreachable today. + // It routes to the same step the overflow does so that "what Edit means" has + // one definition, rather than going stale if the row ever moves into the body. + GroupProfileAction.Edit -> flowNavigator.navigateTo(ChatStep.EditGroup) } }, ) } } +/** + * The overflow row's leading glyph, matching `ListItem`'s, so the one row inside the menu and the + * rows on the screen behind it line their icons up at the same size. + */ +private val OverflowIconSize = 24.dp + +/** + * The profile's top-right overflow — one row, Edit, and only for a viewer who may use it. + * + * Draws nothing at all when the list is empty rather than a disabled button: an overflow that + * opens on nothing is worse than no overflow, and `canEdit` is stable for the life of the screen + * in every case but a permission being revoked under it. + * + * Surface colour and corner are the message long-press menu's, from `ChatTopBar.MessageOverflow`, + * so the two menus in this feature read as one control. The vertical placement is not: this one + * opens *over* its button the way Chrome's toolbar overflow does, rather than below it. A menu + * dropped clear of a top-right button pushes its first row toward the middle of the screen and + * leaves the button stranded above it; covering the button puts the row where the thumb already + * is. `ChatTopBar`'s menu still drops below, which is the remaining inconsistency. + * + * The row is icon-led for the same reason the rows below it are. `DropdownMenu` is laid out to a + * 112dp minimum width, which a label as short as "Edit" leaves more than half empty; the glyph + * fills the leading space the trailing space is measured against, so the menu reads as a row + * rather than as a blank rounded rectangle with a word in the corner. + */ +@Composable +private fun GroupProfileOverflow( + canEdit: Boolean, + onAction: (GroupProfileAction) -> Unit, +) { + val items = groupProfileOverflowItems(canEdit) + if (items.isEmpty()) return + + var expanded by remember { mutableStateOf(false) } + // Measured rather than assumed: `AppBarDefaults.Overflow` sizes its own circle, so the pull-up + // that lands the menu's top edge on the button's top has to come from the anchor itself. + var anchorHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + Box(modifier = Modifier.onSizeChanged { anchorHeight = with(density) { it.height.toDp() } }) { + AppBarDefaults.Overflow( + modifier = Modifier.testTag("action_group_profile_overflow"), + onClick = { expanded = true }, + ) + DropdownMenu( + expanded = expanded, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = -anchorHeight), + onDismissRequest = { expanded = false }, + ) { + items.forEach { item -> + DropdownMenuItem( + modifier = Modifier.testTag("action_edit_group"), + leadingIcon = { + Image( + modifier = Modifier.size(OverflowIconSize), + painter = item.icon, + colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), + contentDescription = null, + ) + }, + text = { + Text( + text = item.name, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + }, + onClick = { + expanded = false + onAction(item.action) + }, + ) + } + } + } +} + @VisibleForTesting @Composable internal fun GroupProfileHeader( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt new file mode 100644 index 0000000000..3128415c47 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +/** + * `EditChatRequest.Title.value` — `min_len 1, max_len 64`, checked here before the call. + * + * The bound is on the *trimmed* title, and the trimmed title is what gets sent: surrounding + * whitespace is invisible in every place a group title is drawn, so counting it would let a title + * that looks empty through and a title that looks short fail. + * + * Length is counted in code points, not [String.length]. protovalidate's `max_len` on a string + * counts Unicode code points, while Kotlin counts UTF-16 units, so a title of 40 emoji measures 80 + * here and 40 at the server. Using [String.length] would refuse titles the contract accepts, and + * would do it only for non-BMP characters — the case least likely to be noticed. + */ +internal object ChatTitle { + const val MIN_LENGTH = 1 + const val MAX_LENGTH = 64 + + /** The value to send for [input] — trimmed, as [isValid] measures it. */ + fun normalize(input: CharSequence): String = input.toString().trim() + + fun lengthOf(input: CharSequence): Int = + normalize(input).let { it.codePointCount(0, it.length) } + + fun isValid(input: CharSequence): Boolean = lengthOf(input) in MIN_LENGTH..MAX_LENGTH +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt new file mode 100644 index 0000000000..1211b51f54 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.MenuItem +import com.flipcash.features.messenger.R + +/** What the edit list can open. One entry per editable field on `EditChatRequest`. */ +internal sealed interface EditGroupAction { + data object Picture : EditGroupAction + data object Name : EditGroupAction +} + +/** + * The group's picture — node 10187:110373's first row, where the design calls it "Icon". + * + * Labelled Picture rather than the design's "Icon": it writes `EditChatRequest.picture`, and + * picture is the word the rest of the app already uses for it. + */ +internal data object EditGroupPicture : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Image) + + override val name: String + @Composable get() = stringResource(R.string.title_editGroupPicture) + + override val action: EditGroupAction = EditGroupAction.Picture +} + +/** + * The group's title. + * + * Not in node 10187:110373 — the design's four rows are Icon, Membership Card, Description and + * Social Links — but `EditChatRequest.title` is one of exactly two fields the contract offers, so + * the row exists and takes the picture row's styling rather than inventing its own. + */ +internal data object EditGroupName : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.TextFields) + + override val name: String + @Composable get() = stringResource(R.string.title_editGroupName) + + override val action: EditGroupAction = EditGroupAction.Name +} + +/** + * The edit list, in the order node 10187:110373 puts them in — the picture first. + * + * A function rather than a constant so the list has one definition that both the screen and its + * test read. Membership Card, Description and Social Links are deliberately absent: flipcash2 + * 0.11.0's `EditChatRequest` has `title` and `picture` and nothing else, so a row for any of them + * would be a control with nothing to write. + */ +internal fun editGroupItems(): List> = + listOf(EditGroupPicture, EditGroupName) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt new file mode 100644 index 0000000000..9e01b8b726 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt @@ -0,0 +1,145 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The group's title, behind node 10187:110373's Name row. + * + * Takes [chatViewModel] for the chat it is editing and the title it starts from — the same reason + * `GroupProfileScreen` does, and the reason this step carries no arguments — and keeps the edit + * itself in its own nav-entry-scoped view model, so backing out drops the draft with the entry. + */ +@Composable +internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = chatState.subject as? ChatSubject.Group + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val keyboard = rememberKeyboardController() + + // Keyed on the chat rather than Unit: the seed has to wait for the group to resolve, and must + // not run again afterwards or it would overwrite the edit in progress. + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + viewModel.dispatchEvent( + EditGroupNameViewModel.Event.Initialize( + chatId = resolved.chatId, + title = resolved.groupTitle.orEmpty(), + ) + ) + } + + // Only OK gets here. A refusal — a moderated title above all — keeps the user on the screen + // with what they typed, which is the only state an amendment can start from. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + // Laid out as NameEntryScreen lays out the same edit: the bar carries the back control and no + // title, and the heading sits inside the scaffold above the field, so it scrolls and insets + // with the content rather than floating in a bar over it. + Column { + AppBarWithTitle( + onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, + ) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset, + ), + text = stringResource(R.string.title_setGroupName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + } + }, + ) + }, + ) { padding -> + val focusRequester = remember { FocusRequester() } + Column(modifier = Modifier.padding(padding)) { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + sublabel = stringResource(R.string.subtitle_groupNameLength), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + } + }, + ) + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt new file mode 100644 index 0000000000..eba7686f8f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -0,0 +1,207 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.moderation.moderationDescription +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.EditChatError +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * The group-title edit behind node 10187:110373's Name row. + * + * Shaped after `NameEntryViewModel`, which cannot itself be reused: it is bound to the signed-in + * user's profile at both ends — seeding from `UserManager` and writing through + * `ProfileController.setDisplayName` — so what carries over is the screen's shape and its + * moderation wording, not the view model. + * + * Seeded by the screen from the conversation's own state rather than by fetching: the title is + * already on `ChatSubject.Group`, kept current by the roster and the event stream. + */ +@HiltViewModel +class EditGroupNameViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + // The base hops to Dispatchers.Default to publish events. Taking it from the injected + // provider instead keeps every dispatcher this class touches on one seam. + defaultDispatcher = dispatchers.Default, +) { + data class State( + val titleFieldState: TextFieldState = TextFieldState(), + val chatId: ChatId? = null, + /** The stored title: the baseline an edit is measured against. */ + val savedTitle: String = "", + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + /** `min_len 1, max_len 64`, counted as [ChatTitle] counts it. */ + val isValid: Boolean + get() = ChatTitle.isValid(titleFieldState.text) + + /** Nothing to send when the title is what it already was. */ + val isChanged: Boolean + get() = ChatTitle.normalize(titleFieldState.text) != savedTitle.trim() + + val canSubmit: Boolean + get() = isValid && isChanged && processingState.isIdle && chatId != null + } + + sealed interface Event { + /** The screen handing over the chat this is editing, and the title it currently has. */ + data class Initialize(val chatId: ChatId, val title: String) : Event + + /** + * Save, pressed — or the keyboard's Done. The rename is only proposed here: what sends it + * is [SubmitTitle], dispatched by the confirmation's own action. + */ + data object SaveClicked : Event + + /** The confirmed rename. Reachable only through the prompt [SaveClicked] raises. */ + data object SubmitTitle : Event + + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The edit was accepted and stored; the screen can leave. */ + data object OnTitleAccepted : Event + } + + init { + eventFlow + .filterIsInstance() + // Seeds once. A later title arriving from the stream while the user is mid-edit would + // otherwise overwrite what they are typing, and this screen exists for exactly as long + // as one edit — there is nothing to reconcile with. + .onEach { event -> + stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd(event.title) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + // The same gate the button draws itself from, re-read because the keyboard's Done + // reaches here without consulting it. + if (!state.canSubmit) return@onEach + + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeGroupName), + message = resources.getString( + R.string.prompt_description_changeGroupName, + ChatTitle.normalize(state.titleFieldState.text), + ), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeGroupName)) { + viewModelScope.launch { + // The bar dismisses on an animation, and the submit's spinner + // belongs to the screen behind it — it would start underneath. + delay(150.milliseconds) + dispatchEvent(Event.SubmitTitle) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + val chatId = state.chatId ?: return@onEach + // Re-checked rather than trusted from the button's enabled state: the keyboard's + // Done action reaches here too. + if (!state.isValid) return@onEach + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + // Title only. The picture field stays unset so the server leaves the picture + // alone — a full-object write here would clear it. + val result = chatCoordinator.editChat( + chatId = chatId, + parameters = titleOnly(state.titleFieldState.text), + ) + + result.onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + viewModelScope.launch { + delay(500.milliseconds) + dispatchEvent(Event.OnTitleAccepted) + dispatchEvent(Event.UpdateProcessingState()) + } + }.onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceEditFailure(cause) + } + }.launchIn(viewModelScope) + } + + /** + * `EditChatResponse.Result`, less `OK`. + * + * Every arm leaves the user on this screen with the field as they typed it — a refused title + * is one to amend, and `TITLE_MODERATED` in particular is only actionable if what was rejected + * is still in front of them. + */ + private fun announceEditFailure(cause: Throwable) { + when (cause) { + is EditChatError.TitleModerated -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupTitleNotAllowed), + message = resources.getString(moderationDescription(cause.category)), + ) + + is EditChatError.Denied -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupEditDenied), + message = resources.getString(R.string.error_description_groupEditDenied), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupEditFailed), + message = resources.getString(R.string.error_description_groupEditFailed), + ) + } + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy(chatId = event.chatId, savedTitle = event.title) + } + + Event.SaveClicked -> { state -> state } + Event.SubmitTitle -> { state -> state } + Event.OnTitleAccepted -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt new file mode 100644 index 0000000000..425390ed35 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt @@ -0,0 +1,202 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.core.data.isLoading +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White50 +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The group's picture, behind node 10187:110373's Icon row. + * + * Laid out as `PhotoSelectionScreen` is — a round well that opens the picker, the stored picture + * inside it until a pick replaces it, Save underneath — so the two picture edits in the app are + * the same screen to use. The view model behind it differs because the write does: + * `EditChat` against a group rather than the signed-in user's own profile. + */ +@Composable +internal fun EditGroupPictureScreen(chatViewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = chatState.subject as? ChatSubject.Group + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + viewModel.dispatchEvent( + EditGroupPictureViewModel.Event.Initialize( + chatId = resolved.chatId, + picture = resolved.picture, + title = resolved.groupTitle.orEmpty(), + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + val pickMedia = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + if (uri != null) { + trace(tag = TAG, message = "image selected @ $uri", type = TraceType.User) + viewModel.dispatchEvent(EditGroupPictureViewModel.Event.OnImageSelected(uri)) + } else { + trace(tag = TAG, message = "No image selected", type = TraceType.User) + } + } + + Column { + AppBarWithTitle( + title = stringResource(R.string.title_setGroupPicture), + titleAlignment = Alignment.CenterHorizontally, + // The pick is dropped by leaving: the view model is scoped to this nav entry, so + // popping it takes the draft with it and the stored picture stands. + onBackIconClicked = { flowNavigator.back() }, + ) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + // The bar sits outside the scaffold's horizontal inset, as PhotoSelectionScreen and + // EditGroupNameScreen place theirs. Nesting it inside indents the back control by + // [inset], putting it out of line with the back control on every other screen. + topBar = {}, + bottomBar = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + viewModel.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) + }, + ) + } + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(150.dp) + .background(color = CodeTheme.colors.divider, shape = CircleShape) + .clip(CircleShape) + .clickable { + pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + contentAlignment = Alignment.Center, + ) { + Crossfade(targetState = state.image) { pending -> + when { + pending.isLoaded() -> { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(pending.data) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + + pending.isLoading() && pending.dataOrNull != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } + + // No pick pending: the group's stored picture, so the screen opens on the + // current icon rather than an empty well. Save stays disabled until a pick + // is made, so this cannot be mistaken for one. + state.savedPicture != null -> { + ContactAvatar( + image = state.savedPicture, + displayName = state.groupTitle, + access = BlobAccessContext.Owned, + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = White50, + modifier = Modifier.size(48.dp), + ) + } + } + } + } + } + } + } + } +} + +private const val TAG = "EditGroupPicture" diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt new file mode 100644 index 0000000000..baf200b340 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -0,0 +1,316 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.moderation.moderationDescription +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.RejectionReason +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * The group-picture edit behind node 10187:110373's Icon row. + * + * The pick-prepare-upload path is `CreateGroupViewModel`'s, because group creation already + * collects a group picture and the two have to produce the same thing: an ORIGINAL rendition + * inside the server's current [UploadPolicy], uploaded through [BlobStorageCoordinator], whose + * `upload` returns only once the blob is READY. Nothing new uploads here. + * + * That READY guarantee is what makes the ordering correct: `EditChat` answers + * `PICTURE_BLOB_NOT_ACCEPTED` for a blob still PENDING or PROCESSING, and the only way to reach + * the edit call below is through a successful `upload`. + */ +@HiltViewModel +class EditGroupPictureViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val chatCoordinator: ChatCoordinator, + private val blobStorage: BlobStorageCoordinator, + private val imagePreparer: ImageUploadPreparer, + private val contentReader: ContentReader, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + // The base hops to Dispatchers.Default to publish events. Taking it from the injected + // provider instead keeps every dispatcher this class touches on one seam. + defaultDispatcher = dispatchers.Default, +) { + data class State( + val chatId: ChatId? = null, + /** The group's stored picture — what the well shows until a pick replaces it. */ + val savedPicture: MediaItem? = null, + val groupTitle: String = "", + /** The pending pick, re-encoded and cached on disk. Null until one is made. */ + val image: Loadable = Loadable.Loading(), + val mimeType: String? = null, + val uploadPolicy: UploadPolicy? = null, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + /** Only a fresh pick can be saved; the stored picture is not a change to itself. */ + val isChanged: Boolean + get() = image.dataOrNull != null + + val canSubmit: Boolean + get() = isChanged && processingState.isIdle && chatId != null + } + + sealed interface Event { + data class Initialize( + val chatId: ChatId, + val picture: MediaItem?, + val title: String, + ) : Event + + data class OnImageSelected(val image: Uri) : Event + data class OnImageCached(val image: Uri, val mimeType: String) : Event + data object DiscardPendingImage : Event + data class UploadPolicyLoaded(val policy: UploadPolicy) : Event + /** + * Save, pressed. The new picture is only proposed here: what uploads and sends it is + * [SubmitPicture], dispatched by the confirmation's own action. + */ + data object SaveClicked : Event + + /** The confirmed change. Reachable only through the prompt [SaveClicked] raises. */ + data object SubmitPicture : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnPictureAccepted : Event + } + + init { + blobStorage.policy + .filterNotNull() + .onEach { dispatchEvent(Event.UploadPolicyLoaded(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + // Re-encode inside the policy's dimension and byte caps, off the main thread — + // the same call group creation makes, so both produce blobs the server will take. + when (val outcome = imagePreparer.prepare( + uri = event.image, + policy = stateFlow.value.uploadPolicy, + fileNamePrefix = "group_picture", + )) { + is ImageUploadPreparer.Outcome.Prepared -> outcome.uri to outcome.mimeType + ImageUploadPreparer.Outcome.Unsupported -> { + rejectImage( + title = R.string.error_title_imageNotSupported, + message = R.string.error_description_imageNotSupported, + ) + null + } + + ImageUploadPreparer.Outcome.TooLarge -> { + rejectImage( + title = R.string.error_title_imageTooLarge, + message = R.string.error_description_imageTooLarge, + ) + null + } + + // The preparer has already said why in the log; there is nothing to add. + ImageUploadPreparer.Outcome.Unreadable -> null + } + } + .flowOn(dispatchers.IO) + .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (!stateFlow.value.canSubmit) return@onEach + + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeGroupPicture), + message = resources.getString(R.string.prompt_description_changeGroupPicture), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeGroupPicture)) { + viewModelScope.launch { + // The bar dismisses on an animation, and the upload's spinner + // belongs to the screen behind it — it would start underneath. + delay(150.milliseconds) + dispatchEvent(Event.SubmitPicture) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { submit() } + .launchIn(viewModelScope) + } + + private suspend fun submit() { + val state = stateFlow.value + val chatId = state.chatId ?: return + val uri = state.image.dataOrNull ?: return + val mimeType = state.mimeType ?: return + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + val blobId = uploadPicture(uri, mimeType) + if (blobId == null) { + dispatchEvent(Event.UpdateProcessingState()) + return + } + + // Picture only. The title field stays unset so the server leaves the title alone. + chatCoordinator.editChat( + chatId = chatId, + parameters = pictureOnly(blobId), + ).onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnPictureAccepted) + dispatchEvent(Event.UpdateProcessingState()) + }.onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceEditFailure(cause) + } + } + + /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */ + private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? { + val bytes = contentReader.readBytes(uri) + if (bytes == null) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + return null + } + + return blobStorage.upload(bytes = bytes, mimeType = mimeType) + .onFailure { cause -> + dispatchEvent(Event.DiscardPendingImage) + announcePictureRejection(cause) + } + .getOrNull() + } + + private fun announcePictureRejection(cause: Throwable) { + val rejection = (cause as? BlobRejectedException)?.rejection + when (rejection?.reason) { + RejectionReason.MODERATION -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(moderationDescription(rejection.flaggedCategory)), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + } + + private fun announceEditFailure(cause: Throwable) { + when (cause) { + // Storage took the blob and EditChat would not have it, so the pick is what has to + // change — it is dropped rather than left on screen inviting the same upload again. + is EditChatError.PictureBlobNotAccepted -> { + dispatchEvent(Event.DiscardPendingImage) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed), + ) + } + + is EditChatError.Denied -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupEditDenied), + message = resources.getString(R.string.error_description_groupEditDenied), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupEditFailed), + message = resources.getString(R.string.error_description_groupEditFailed), + ) + } + } + + private fun rejectImage(title: Int, message: Int) { + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy( + chatId = event.chatId, + savedPicture = event.picture, + groupTitle = event.title, + ) + } + + is Event.OnImageSelected -> { state -> + // Held as Loading with the source uri so the well can show the pick while it + // is being re-encoded, instead of going blank between tap and preview. + state.copy(image = Loadable.Loading(event.image)) + } + + is Event.OnImageCached -> { state -> + state.copy( + image = Loadable.Loaded(event.image), + mimeType = event.mimeType, + ) + } + + Event.DiscardPendingImage -> { state -> + state.copy(image = Loadable.Loading(), mimeType = null) + } + + is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) } + Event.SaveClicked -> { state -> state } + Event.SubmitPicture -> { state -> state } + Event.OnPictureAccepted -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt new file mode 100644 index 0000000000..54b096346d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuList +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold + +/** + * The group's edit list — node 10187:110373. + * + * Two rows, because the contract has two fields. The design's other rows (Membership Card, + * Description, Social Links) are left out rather than stubbed: `EditChatRequest` in flipcash2 + * 0.11.0 carries `title` and `picture` only, so each of them would be a row that opens onto + * nothing the server would accept. + * + * The design titles this screen "Edit Community"; it is titled "Edit Group" here because "Group" + * is what the rest of the app calls this thing in front of the user — "New Public Group", "Group + * Name" — and one screen switching vocabulary mid-flow is worse than differing from the mock. + * + * Stateless beyond navigation: both rows own their own edit and their own failure, so there is + * nothing here to hold. The list itself is not gated — `canEdit` gates the door to it on + * [com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen], and the server + * re-checks on the write regardless. + */ +@Composable +internal fun EditGroupScreen() { + val flowNavigator = rememberFlowNavigator() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_editGroup), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + MenuList( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + items = editGroupItems(), + showChevrons = true, + onItemClick = { item -> + when (item.action) { + EditGroupAction.Picture -> flowNavigator.navigateTo(ChatStep.EditGroupPicture) + EditGroupAction.Name -> flowNavigator.navigateTo(ChatStep.EditGroupName) + } + }, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt new file mode 100644 index 0000000000..64a8b753dd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.EditChatParameters + +/** + * The two edits this screen can make, each named so the "send only what changed" rule is a thing + * the code states rather than a thing every call site has to remember. + * + * `EditChatRequest` leaves a field unchanged when it is unset, so a partial update is the whole + * mechanism: [titleOnly] must not carry a picture and [pictureOnly] must not carry a title. + * Building the parameters inline at each call site made that a convention holding by inspection — + * and an `EditChatParameters(title = ..., picture = state.picture)` written later, in the shape + * most object updates take, would silently overwrite the other field with whatever the screen + * happened to be holding. + */ +internal fun titleOnly(title: CharSequence): EditChatParameters = + EditChatParameters(title = ChatTitle.normalize(title)) + +internal fun pictureOnly(blobId: BlobId): EditChatParameters = + EditChatParameters(picture = blobId) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt new file mode 100644 index 0000000000..fb497c7152 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt @@ -0,0 +1,32 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Who is offered the group's Edit row. + * + * `canEdit` is computed by the server and arrives on `ViewerState.Permissions`. The reason this is + * worth a test of its own is that several local signals — being a member, having created the chat, + * the chat being a group at all — look like they answer the question and none of them is the + * answer. Offering Edit on a local guess produces a row that opens a screen whose Save then comes + * back `DENIED`, which reads to the user as the app being broken rather than as a permission. + */ +class GroupProfileOverflowTest { + + @Test + fun `a viewer who may edit is offered exactly the Edit row`() { + val items = groupProfileOverflowItems(canEdit = true) + + assertEquals(1, items.size) + assertEquals(GroupProfileAction.Edit, items.single().action) + } + + @Test + fun `a viewer who may not edit is offered nothing`() { + // Empty rather than disabled: the overflow button itself is drawn only when this has + // something in it, so no-permission means no button, not a button that opens an empty menu. + assertTrue(groupProfileOverflowItems(canEdit = false).isEmpty()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt new file mode 100644 index 0000000000..d4dd0bd34f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The client half of `EditChatRequest.Title`'s `min_len 1, max_len 64`. + * + * The server enforces it too, so the stake here is not correctness of the write but what the user + * sees: without this the Save button is live on a title the server will refuse, and the refusal + * comes back as a generic failure rather than as the screen keeping the field for them to fix. + */ +class ChatTitleTest { + + @Test + fun `a single character is a valid title`() { + assertTrue(ChatTitle.isValid("a")) + } + + @Test + fun `exactly 64 characters is valid, 65 is not`() { + assertTrue(ChatTitle.isValid("a".repeat(64))) + assertFalse(ChatTitle.isValid("a".repeat(65))) + } + + @Test + fun `an empty title is refused`() { + assertFalse(ChatTitle.isValid("")) + } + + @Test + fun `a title of only whitespace is refused`() { + // It has a length, but every place the title is drawn would render it blank, so counting + // the spaces would let an apparently-empty group name through. + assertFalse(ChatTitle.isValid(" ")) + } + + @Test + fun `surrounding whitespace does not count toward the limit`() { + // 64 real characters padded either side still fits, because the padding is trimmed before + // it is measured and before it is sent. + assertTrue(ChatTitle.isValid(" " + "a".repeat(64) + " ")) + } + + @Test + fun `the value sent is the trimmed one`() { + assertEquals("Book Club", ChatTitle.normalize(" Book Club ")) + } + + @Test + fun `length is counted in code points, not UTF-16 units`() { + // Each of these is one code point and two Kotlin chars. 64 of them is exactly the limit at + // the server; measuring with String.length would call it 128 and refuse a legal title. + val emoji = "🎉" // party popper + assertEquals(128, emoji.repeat(64).length) + assertEquals(64, ChatTitle.lengthOf(emoji.repeat(64))) + assertTrue(ChatTitle.isValid(emoji.repeat(64))) + assertFalse(ChatTitle.isValid(emoji.repeat(65))) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt new file mode 100644 index 0000000000..7e6195baee --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt @@ -0,0 +1,180 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Save proposes; the prompt commits. + * + * A rename and a new picture are both visible to everyone in the group, so neither leaves the + * device on the strength of one tap. The gate lives in the view model rather than the screen + * precisely so these can assert the part that matters — that Save on its own sends nothing — in a + * plain unit test instead of an instrumented one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class EditGroupConfirmationTest { + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(32) { 9 }.toList()) + + private val scheduler = TestCoroutineScheduler() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + BottomBarManager.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun nameModel() = EditGroupNameViewModel( + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), + chatCoordinator = chatCoordinator, + resources = resources, + ).apply { + dispatchEvent(EditGroupNameViewModel.Event.Initialize(chatId, title = "Dogs")) + } + + @Test + fun `saving a rename asks before it sends`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `confirming the rename is what sends it`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + // The success path reads the edited chat back off the result, so a relaxed stub's + // bare Object would fail there rather than at the assertion. + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + BottomBarManager.messages.value.last().actions.first().onClick() + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.editChat(chatId, any()) } + } + + @Test + fun `dismissing the rename prompt keeps the draft and sends nothing`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + // What a cancel does: the prompt goes, nothing else is dispatched. + BottomBarManager.clear() + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals("Dog Park", model.stateFlow.value.titleFieldState.text.toString()) + } + + @Test + fun `an unchanged name raises no prompt`() = runTest(scheduler) { + // Save is disabled for it, but the keyboard's Done action reaches the same event. + val model = nameModel() + advanceUntilIdle() + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertTrue(BottomBarManager.messages.value.isEmpty()) + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + } + + private fun pictureModel() = EditGroupPictureViewModel( + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), + chatCoordinator = chatCoordinator, + blobStorage = mockk(relaxed = true), + imagePreparer = mockk(relaxed = true), + contentReader = mockk(relaxed = true), + resources = resources, + ).apply { + dispatchEvent( + EditGroupPictureViewModel.Event.Initialize(chatId, picture = null, title = "Dogs") + ) + } + + @Test + fun `saving a picture asks before it uploads`() = runTest(scheduler) { + val model = pictureModel() + advanceUntilIdle() + model.dispatchEvent( + EditGroupPictureViewModel.Event.OnImageCached( + image = Uri.parse("file:///tmp/pick.jpg"), + mimeType = "image/jpeg", + ) + ) + advanceUntilIdle() + + model.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `a picture with nothing picked raises no prompt`() = runTest(scheduler) { + val model = pictureModel() + advanceUntilIdle() + + model.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertTrue(BottomBarManager.messages.value.isEmpty()) + } +} + +private class TestDispatchers(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { + override val Default: CoroutineDispatcher get() = dispatcher + override val Main: CoroutineDispatcher get() = dispatcher + override val IO: CoroutineDispatcher get() = dispatcher +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt new file mode 100644 index 0000000000..5d07226cef --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.chat.BlobId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * `EditChat` leaves a field unchanged when it is unset, which makes an over-filled request + * destructive rather than merely wasteful: renaming a group while also sending the picture field + * would rewrite the picture, and sending it as null would clear it. + * + * These assert the one property that matters at the call site — each edit carries its own field + * and nothing else. + */ +class PartialEditsTest { + + @Test + fun `renaming sends a title and no picture`() { + val parameters = titleOnly("Book Club") + + assertEquals("Book Club", parameters.title) + assertNull(parameters.picture, "a rename must not touch the group's picture") + } + + @Test + fun `renaming sends the trimmed title`() { + assertEquals("Book Club", titleOnly(" Book Club ").title) + } + + @Test + fun `changing the picture sends a blob and no title`() { + val blob = BlobId(byteArrayOf(1, 2, 3)) + + val parameters = pictureOnly(blob) + + assertEquals(blob, parameters.picture) + assertNull(parameters.title, "a picture change must not touch the group's name") + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index f9ad7c9b71..53d33043d6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -8,12 +8,12 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.moderation.moderationDescription import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.tipping.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.BlobRejectedException -import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatMetadata @@ -496,29 +496,6 @@ internal class CreateGroupViewModel @Inject constructor( } } - /** The app's existing moderation-category wording, as the username and photo screens map it. */ - @StringRes - private fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = - when (category) { - ModerationResult.FlaggedCategory.NONE -> - R.string.error_description_imageNotAllowed - - ModerationResult.FlaggedCategory.OTHER -> - R.string.error_description_profileNameNotAllowedFlaggedOther - - ModerationResult.FlaggedCategory.NSFW -> - R.string.error_description_profileNameNotAllowedFlaggedNsfw - - ModerationResult.FlaggedCategory.IMPERSONATION -> - R.string.error_description_profileNameNotAllowedFlaggedImpersonation - - ModerationResult.FlaggedCategory.MISLEADING -> - R.string.error_description_profileNameNotAllowedFlaggedMisleading - - ModerationResult.FlaggedCategory.SPAM -> - R.string.error_description_profileNameNotAllowedFlaggedSpam - } - companion object { @VisibleForTesting internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index bc76c5b11b..b79281d092 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -11,6 +11,7 @@ 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.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer @@ -338,6 +339,24 @@ interface GroupOperations { idempotencyKey: IdempotencyKey, ): Result + /** + * Applies [parameters] to [chatId] and stores the chat the server answers with. + * + * A partial update: [EditChatParameters] leaves every unset field alone, so editing the title + * cannot clear the picture. Requires `ViewerState.Permissions.canEdit`, which is the server's + * to compute — a caller without it gets `DENIED` rather than a local refusal. + * + * The post-edit metadata is persisted from the response rather than refetched, and the + * `titleChanged` / `pictureChanged` echo that follows carries the same absolute values and is + * applied unconditionally, so the two converge instead of compounding. Suppressing the echo + * would be the wrong fix: it cannot be told apart from a *different* editor's change arriving + * in the same window, which is a change this device does need. + */ + suspend fun editChat( + chatId: ChatId, + parameters: EditChatParameters, + ): Result + /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index e1b409ca68..823dccb453 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -6,6 +6,7 @@ import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.StartChatParameters @@ -131,6 +132,24 @@ class GroupFeedDelegate @Inject constructor( } } + /** + * Edits [chatId] and stores the post-edit metadata the server returns. + * + * [persist] rather than a refetch: `EditChat` answers `OK` with the chat as it now stands, so + * a second round trip could only return the same thing later. No membership write — unlike + * [create] and [join], an edit does not change who the caller is to the chat. + */ + override suspend fun editChat( + chatId: ChatId, + parameters: EditChatParameters, + ): Result { + return chatController.editChat(chatId, parameters) + .onSuccess { metadata -> persist(listOf(metadata)) } + .onFailure { + trace(tag = TAG, message = "Edit failed for $chatId", type = TraceType.Error) + } + } + /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */