Skip to content

Commit 4faca0a

Browse files
authored
feat(chat): send photos from the composer and show them in the chat (#1683)
* feat(chat): send photos from the composer and show them in the chat The photo UI on top of the transport and composer PRs, ported from code-ios-app#959. - Attach menu: + grows into a menu card drawn in non-focusable windows above the keyboard, so the keyboard stays up. Back closes the menu only. - Camera: the menu card grows into a camera card, 58% of the screen tall. The camera starts while the menu is open, holds 30 fps, and a shot is staged as a chip at the shutter while the file is still being written. - Picker: Photos opens the system picker sheet; picks become chips and the keyboard comes back up. - Bubbles: photo bubbles with a BlurHash placeholder, upload progress, retry, and "This photo can't be displayed" for one that won't open. A sent photo keeps drawing while its row swaps to the stored copy. - Viewer: tapping a photo opens an overlay that grows out of the bubble; drag, close or Back shrinks it back into the bubble. - Replies: a quoted photo shows its thumbnail in the reply strip and in the quote panel, and a restored reply draft keeps it. - Capture files left behind by a killed run are deleted when a chat opens. * fix(chat-ui): blue upload fill, and aim the first shot at its chip The upload fill used CodeTheme.colors.brand, which Flipcash sets to 0xFF19191A, so it barely showed on the dark track. It now uses ChatMediaBlue (0xFF0A84FF, UIKit's systemBlue in dark mode), as iOS does. The camera's more dot shares the same constant. The first camera shot flew toward the top of the screen. Its chip's header row eases in from behind the field's clip, and boundsInRoot() reports a fully clipped node as Rect.Zero, the window's top-left. The landing target now comes from the chip's unclipped position and size. Later shots were fine because the header row was already visible. * fix(chat-ui): keep a sent photo's progress bar up once its bubble is drawn A photo that finished uploading in the composer reaches Sent soon after the tap, but the list scrolls the new bubble in about 450 ms later. On the Seeker the bar showed for 2 frames of a 20 fps recording before it faded, so it read as missing. Once the overlay is drawn it now stays up for at least 600 ms before honouring Sent. A bubble first drawn already sent still never shows it. * fix(messenger): send photos whose upload failed offline Only a failure a retry can't fix blocks the send now. A photo that failed offline goes into the transcript as Sending and retries from there, instead of holding the message in the composer. * fix(messenger): refresh reactions off the event bus Offline, the reaction refresh's RPC doesn't return. Its handler waited on it inline, and the event bus has no buffer, so every event after it queued until the network came back: a picked photo never reached the composer. The refresh is launched now, as the read pointer's already is. * fix(chat-ui): show a moving bar until a photo's bytes start uploading The capsule drew an empty track while the photo was encoding and before the first bytes went up, so a slow start looked like no indicator at all. Any phase without a byte count now slides the blue segment; the fill follows the bytes once they report. * fix(chat-ui): keep a photo's upload fill moving and run it to full The fill eased to each byte report over 200 ms and then sat still until the next one, so a slow upload moved in steps. It now follows the reports linearly over 450 ms, longer than the gap between them, so it keeps moving. When the bytes finish it runs to the end of the track and holds briefly before the moving segment takes over, instead of cutting from part-way.
1 parent 2f62085 commit 4faca0a

54 files changed

Lines changed: 6406 additions & 93 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import com.flipcash.app.currency.RegionSelectionScreen
3737
import com.flipcash.app.deposit.DepositFlowScreen
3838
import com.flipcash.app.invite.InviteContactScreen
3939
import com.flipcash.app.messenger.ChatFlowScreen
40+
import com.flipcash.app.messenger.ChatPhotoViewerScreen
4041
import com.flipcash.app.messenger.E2eeLearnMoreScreen
4142
import com.flipcash.app.messenger.MuteChatScreen
4243
import com.flipcash.app.messenger.ProfileScreen
@@ -131,6 +132,7 @@ fun appEntryProvider(
131132
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
132133
}
133134
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }
135+
annotatedEntry<AppRoute.Messaging.PhotoViewer> { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) }
134136
annotatedEntry<AppRoute.Messaging.E2eeDmInfo> { E2eeLearnMoreScreen(forGroup = false) }
135137
annotatedEntry<AppRoute.Messaging.E2eeGroupInfo> { E2eeLearnMoreScreen(forGroup = true) }
136138

‎apps/flipcash/app/src/main/res/xml/file_paths.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@
55
<cache-path name="share_previews" path="share_previews/" />
66
<!-- Exported tip codes, PNG + SVG (cacheDir/share_exports/). -->
77
<cache-path name="share_exports" path="share_exports/" />
8+
<!-- Chat photos handed to the share sheet (cacheDir/chat_media/). -->
9+
<cache-path name="chat_media" path="chat_media/" />
810
</paths>

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,15 @@ sealed interface AppRoute : NavKey, Parcelable {
460460
com.getcode.navigation.Sheet,
461461
com.getcode.navigation.WrapContentSheet
462462

463+
/**
464+
* The full-screen viewer for the photo in message [messageId] of [chatId]. Carries the
465+
* message's address rather than the photo, which is not something a back stack can hold; the
466+
* viewer reads the message back to learn how to fetch and open it.
467+
*/
468+
@Serializable
469+
@Parcelize
470+
data class PhotoViewer(val chatId: ChatId, val messageId: Long) : Messaging
471+
463472
/** The group counterpart of [E2eeDmInfo], opened from Group Info's footer. */
464473
@Serializable
465474
@Parcelize

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,8 @@
974974
<string name="label_chat_preview_receivedCash">You received %1$s</string>
975975
<string name="label_chat_preview_tippedCash">You tipped %1$s</string>
976976
<string name="label_chat_preview_receivedTip">Someone tipped you %1$s</string>
977+
<string name="label_chat_media_photo">Photo</string>
978+
<string name="label_chat_media_unavailable">This photo can’t be displayed</string>
977979
<string name="label_chat_preview_sentMessage">You: %1$s</string>
978980
<string name="label_chat_preview_senderMessage">%1$s: %2$s</string>
979981
<string name="label_chat_preview_sentCash_bySender">%1$s sent %2$s</string>
@@ -1367,4 +1369,27 @@
13671369
<string name="label_shareOfMaxSupply">Share of max supply</string>
13681370
<string name="label_yourCurrentAppreciation">Your current appreciation</string>
13691371
<string name="label_marketCapChartAccessibility">Price curve for %1$s with %2$s purchased</string>
1372+
<!-- Chat photo messages -->
1373+
<string name="description_chatMediaClose">Close</string>
1374+
<string name="description_chatMediaShare">Share</string>
1375+
<string name="title_chatMediaShare">Share photo</string>
1376+
<string name="description_chatPhotoRemove">Remove photo</string>
1377+
<string name="description_chatPhotoRetry">Retry photo upload</string>
1378+
<string name="description_chatPhotoFailed">Photo can\'t be sent</string>
1379+
<string name="description_chatPhotoUploadProgress">Photo upload progress</string>
1380+
<string name="action_chatAttachCash">Cash</string>
1381+
<string name="action_chatAttachCamera">Camera</string>
1382+
<string name="action_chatAttachPhotos">Photos</string>
1383+
<string name="description_chatAttachMenu">Attach</string>
1384+
<string name="description_chatAttachBack">Back to attach menu</string>
1385+
<string name="action_chatTakePhoto">Take photo</string>
1386+
<string name="action_chatMoreCameraControls">More camera controls</string>
1387+
<string name="action_chatHideCameraControls">Hide camera controls</string>
1388+
<string name="action_chatFlashOn">Turn flash on</string>
1389+
<string name="action_chatFlashOff">Turn flash off</string>
1390+
<string name="action_chatUseFrontCamera">Use front camera</string>
1391+
<string name="action_chatUseBackCamera">Use back camera</string>
1392+
<string name="subtitle_chatCameraPermissionDenied">Turn on Camera in Settings to send photos</string>
1393+
<string name="action_chatOpenSettings">Open Settings</string>
1394+
<string name="subtitle_chatCameraUnavailable">Camera unavailable</string>
13701395
</resources>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.flipcash.app.messenger
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.runtime.LaunchedEffect
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.setValue
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.graphics.Color
14+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
15+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
16+
import com.flipcash.app.messenger.internal.ChatPhotoViewerViewModel
17+
import com.flipcash.services.models.chat.ChatId
18+
import com.flipcash.shared.chat.ui.media.ChatMediaViewer
19+
import com.flipcash.shared.chat.ui.media.rememberSharePhoto
20+
import com.getcode.navigation.core.LocalCodeNavigator
21+
22+
/** The full-screen photo, behind `AppRoute.Messaging.PhotoViewer`. Black, dismissed by back or a pull. */
23+
@Composable
24+
fun ChatPhotoViewerScreen(chatId: ChatId, messageId: Long) {
25+
val viewModel = hiltViewModel<ChatPhotoViewerViewModel>()
26+
LaunchedEffect(chatId, messageId) { viewModel.load(chatId, messageId) }
27+
val photo by viewModel.photo.collectAsStateWithLifecycle()
28+
val navigator = LocalCodeNavigator.current
29+
var fullImageLoaded by remember { mutableStateOf(false) }
30+
val share = rememberSharePhoto(photo)
31+
32+
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
33+
ChatMediaViewer(
34+
model = photo,
35+
fullImageLoaded = fullImageLoaded,
36+
onFullImageLoaded = { fullImageLoaded = true },
37+
onShare = share,
38+
onDismiss = { navigator.pop() },
39+
)
40+
}
41+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal
33
import com.flipcash.shared.chat.ChatDraftReply
44
import com.flipcash.shared.chat.ChatDraftSnippet
55
import com.flipcash.shared.chat.models.ChatQuote
6+
import com.flipcash.shared.chat.media.ChatMediaText
67
import com.flipcash.shared.chat.models.ChatQuoteSnippet
78
import com.getcode.opencode.model.financial.CurrencyCode
89
import com.getcode.opencode.model.financial.Fiat
@@ -22,6 +23,9 @@ internal fun ChatQuote.toDraftReply(): ChatDraftReply = ChatDraftReply(
2223
senderIdHex = senderIdHex,
2324
snippet = when (val snippet = snippet) {
2425
is ChatQuoteSnippet.Text -> ChatDraftSnippet.Text(snippet.body)
26+
// A stored draft carries words only; the thumbnail is not persisted with it.
27+
is ChatQuoteSnippet.Photo ->
28+
ChatDraftSnippet.Text(ChatMediaText.preview(snippet.caption, "Photo"))
2529
is ChatQuoteSnippet.Cash -> ChatDraftSnippet.Cash(
2630
quarks = snippet.amount.quarks,
2731
currencyCode = snippet.amount.currencyCode.name,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.flipcash.app.messenger.internal
2+
3+
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import com.flipcash.services.models.chat.ChatId
6+
import com.flipcash.shared.chat.ChatCoordinator
7+
import com.flipcash.shared.chat.media.ChatPhoto
8+
import com.flipcash.shared.chat.ui.media.photoBody
9+
import dagger.hilt.android.lifecycle.HiltViewModel
10+
import kotlinx.coroutines.flow.MutableStateFlow
11+
import kotlinx.coroutines.flow.StateFlow
12+
import kotlinx.coroutines.launch
13+
import javax.inject.Inject
14+
15+
/**
16+
* Reads the photo behind a viewer route. The route carries only the message's address, so this looks
17+
* the message up and builds the Coil model the transcript's bubble loads from, which is what lets the
18+
* viewer open on what the bubble already cached.
19+
*/
20+
@HiltViewModel
21+
internal class ChatPhotoViewerViewModel @Inject constructor(
22+
private val chatCoordinator: ChatCoordinator,
23+
) : ViewModel() {
24+
25+
private val _photo = MutableStateFlow<ChatPhoto?>(null)
26+
val photo: StateFlow<ChatPhoto?> = _photo
27+
28+
private var loaded: Pair<ChatId, Long>? = null
29+
30+
fun load(chatId: ChatId, messageId: Long) {
31+
if (loaded == chatId to messageId) return
32+
loaded = chatId to messageId
33+
viewModelScope.launch {
34+
val message = chatCoordinator.getMessage(chatId, messageId) ?: return@launch
35+
val rendition = message.content.firstNotNullOfOrNull { it.photoBody() }
36+
?.items?.firstOrNull()
37+
?.let { item -> item.rendition(com.flipcash.services.models.chat.MediaItemRendition.Role.ORIGINAL) ?: item.renditions.firstOrNull() }
38+
?: return@launch
39+
_photo.value = ChatPhoto(
40+
chatId = chatId,
41+
rendition = rendition,
42+
senderId = message.senderId,
43+
sealed = message.encryption != null,
44+
redacted = message.redacted,
45+
)
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)