Skip to content

Commit 1800bdb

Browse files
committed
feat(chat): formatting tools in the chat composer
1 parent e115d7c commit 1800bdb

12 files changed

Lines changed: 1063 additions & 2 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,20 @@
14331433
<string name="action_chatAttachCamera">Camera</string>
14341434
<string name="action_chatAttachPhotos">Photos</string>
14351435
<string name="description_chatAttachMenu">Attach</string>
1436+
<string name="description_chatFormatToggle">Formatting</string>
1437+
<string name="description_chatFormatBold">Bold</string>
1438+
<string name="description_chatFormatItalic">Italic</string>
1439+
<string name="description_chatFormatStrike">Strikethrough</string>
1440+
<string name="description_chatFormatCode">Code</string>
1441+
<string name="description_chatFormatLink">Link</string>
1442+
<string name="description_chatFormatBulleted">Bulleted list</string>
1443+
<string name="description_chatFormatNumbered">Numbered list</string>
1444+
<string name="description_chatFormatQuote">Quote</string>
1445+
<string name="description_chatFormatCodeBlock">Code block</string>
1446+
<string name="title_chatFormatLink">Add a link</string>
1447+
<string name="hint_chatFormatLink">https://</string>
1448+
<string name="error_chatFormatLink">Enter a single link, like https://flipcash.com</string>
1449+
<string name="action_chatFormatLinkAdd">Add</string>
14361450
<string name="description_chatAttachBack">Back to attach menu</string>
14371451
<string name="action_chatTakePhoto">Take photo</string>
14381452
<string name="action_chatMoreCameraControls">More camera controls</string>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ internal class ChatViewModel @AssistedInject constructor(
276276
*/
277277
val webLinkPreviewsEnabled: StateFlow<Boolean> = featureFlags.observe(FeatureFlag.WebLinkPreviews)
278278

279+
/** Whether the composer offers the formatting tools. Rendering formatted text is not flagged. */
280+
val composerFormattingEnabled: StateFlow<Boolean> = featureFlags.observe(FeatureFlag.ChatFormattingComposer)
281+
282+
279283
/**
280284
* Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened
281285
* straight from a notification can beat them to it, and a card drawn before its saved answer

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import com.flipcash.shared.common.ui.profile.PrefetchProfileCover
7575
internal fun MessengerScreen(viewModel: ChatViewModel) {
7676
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
7777
val webLinkPreviewsEnabled by viewModel.webLinkPreviewsEnabled.collectAsStateWithLifecycle()
78+
val composerFormattingEnabled by viewModel.composerFormattingEnabled.collectAsStateWithLifecycle()
7879
val previewsReady by viewModel.previewsReady.collectAsStateWithLifecycle()
7980
val messages = viewModel.messages.collectAsLazyPagingItems()
8081
val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle()
@@ -398,6 +399,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
398399
onAction = chatActionHandler,
399400
dispatch = viewModel::dispatchEvent,
400401
topBarHeight = barHeight,
402+
formattingEnabled = composerFormattingEnabled,
401403
)
402404
}
403405
if (behindBackdrop) {

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import com.flipcash.shared.chat.models.ChatActionHandler
9595
import com.flipcash.services.models.chat.ChatRuleRequirement
9696
import com.getcode.theme.CodeTheme
9797
import com.getcode.ui.components.chat.ChatInput
98+
import com.getcode.ui.components.chat.ComposerFormatToolbarHost
9899
import com.getcode.ui.components.chat.ChatInputSubmit
99100
import com.getcode.ui.core.drawWithGradient
100101
import com.getcode.ui.core.measured
@@ -132,6 +133,7 @@ internal fun UserControlBottomBar(
132133
onAction: ChatActionHandler,
133134
dispatch: (ChatViewModel.Event) -> Unit,
134135
topBarHeight: Dp = 0.dp,
136+
formattingEnabled: Boolean = false,
135137
) {
136138
if (state.isAnonymous) {
137139
DeactivatedChatBottomBar()
@@ -390,6 +392,8 @@ internal fun UserControlBottomBar(
390392
.padding(horizontal = sideInset),
391393
) { if (state.editing != null) cancelEdit() else sendCash() }
392394
}
395+
val formatting = rememberComposerFormatting(state.chatInputState)
396+
val markerDimming = rememberMarkerDimming(formattingEnabled)
393397
Row(
394398
modifier = Modifier
395399
.fillMaxWidth()
@@ -411,10 +415,18 @@ internal fun UserControlBottomBar(
411415
)
412416
}
413417
}
418+
ComposerFormatToolbarHost(
419+
enabled = formattingEnabled,
420+
items = { formatting.toolbarItems() },
421+
) {
414422
ChatInput(
415423
modifier = Modifier
416424
.testTag("chat_message_input")
417425
.hazeBlur(HazeInput.Sources(hazeState), material),
426+
outputTransformation = markerDimming,
427+
stackedLeading = if (formattingEnabled && state.editing == null) {
428+
{ FormatToggleButton(formatting) }
429+
} else null,
418430
outside = if (state.editing != null) cancelEdit else null,
419431
trailingOutside = if (state.editing == null) sendCash else null,
420432
focusRequester = focusRequester,
@@ -540,6 +552,7 @@ internal fun UserControlBottomBar(
540552
}
541553
},
542554
)
555+
}
543556

544557
// An edit starts from a long-press, which leaves the keyboard down, so the
545558
// composer has to claim focus itself or the pre-filled text sits unreachable.
@@ -581,6 +594,18 @@ internal fun UserControlBottomBar(
581594
}
582595
}
583596
}
597+
if (formattingEnabled && canType && formatting.stripOpen && keyboard.visible) {
598+
FormatStrip(formatting, state.chatInputState)
599+
}
600+
if (formatting.linkSheetOpen) {
601+
LinkSheet(
602+
onDismiss = { formatting.linkSheetOpen = false },
603+
onConfirm = {
604+
formatting.addLink(it)
605+
formatting.linkSheetOpen = false
606+
},
607+
)
608+
}
584609
}
585610
}
586611
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
package com.flipcash.app.messenger.internal.screens.components
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.layout.Arrangement
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.navigationBarsPadding
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.size
12+
import androidx.compose.foundation.shape.CircleShape
13+
import androidx.compose.foundation.text.input.OutputTransformation
14+
import androidx.compose.foundation.text.input.TextFieldState
15+
import androidx.compose.material.icons.Icons
16+
import androidx.compose.material.icons.rounded.Code
17+
import androidx.compose.material.icons.rounded.DataObject
18+
import androidx.compose.material.icons.rounded.FormatBold
19+
import androidx.compose.material.icons.rounded.FormatItalic
20+
import androidx.compose.material.icons.rounded.FormatListBulleted
21+
import androidx.compose.material.icons.rounded.FormatListNumbered
22+
import androidx.compose.material.icons.rounded.FormatQuote
23+
import androidx.compose.material.icons.rounded.FormatStrikethrough
24+
import androidx.compose.material.icons.rounded.Link
25+
import androidx.compose.material3.ExperimentalMaterial3Api
26+
import androidx.compose.material3.ModalBottomSheet
27+
import androidx.compose.material3.Text
28+
import androidx.compose.material3.rememberModalBottomSheetState
29+
import androidx.compose.foundation.text.BasicTextField
30+
import androidx.compose.foundation.text.KeyboardOptions
31+
import androidx.compose.runtime.Composable
32+
import androidx.compose.runtime.LaunchedEffect
33+
import androidx.compose.runtime.Stable
34+
import androidx.compose.runtime.getValue
35+
import androidx.compose.runtime.mutableStateOf
36+
import androidx.compose.runtime.remember
37+
import androidx.compose.runtime.setValue
38+
import androidx.compose.ui.Alignment
39+
import androidx.compose.ui.Modifier
40+
import androidx.compose.ui.draw.clip
41+
import androidx.compose.ui.graphics.Color
42+
import androidx.compose.ui.graphics.SolidColor
43+
import androidx.compose.ui.platform.testTag
44+
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.text.SpanStyle
46+
import androidx.compose.ui.text.TextRange
47+
import androidx.compose.ui.text.input.KeyboardType
48+
import androidx.compose.ui.unit.dp
49+
import com.flipcash.core.R as CoreR
50+
import com.flipcash.libs.textformat.ComposerEdit
51+
import com.flipcash.libs.textformat.DraftRanges
52+
import com.flipcash.libs.textformat.InlineFormat
53+
import com.flipcash.libs.textformat.LineFormat
54+
import com.flipcash.libs.textformat.consumedRanges
55+
import com.flipcash.libs.textformat.insertLink
56+
import com.flipcash.libs.textformat.isInlineActive
57+
import com.flipcash.libs.textformat.isLineActive
58+
import com.flipcash.libs.textformat.toggleInline
59+
import com.flipcash.libs.textformat.toggleLine
60+
import com.flipcash.shared.chat.ui.maskableUrlOrNull
61+
import com.flipcash.shared.chat.ui.parseChatText
62+
import com.flipcash.shared.chat.ui.protectedRangesOf
63+
import com.getcode.theme.CodeTheme
64+
import com.getcode.ui.components.chat.FormatAction
65+
import com.getcode.ui.components.chat.ToolbarFormatItem
66+
67+
private val Ranges = DraftRanges { protectedRangesOf(it) }
68+
69+
/**
70+
* The composer's formatting state: whether the strip is open, whether the link sheet is, and the
71+
* edits the buttons make to [field]. Every edit goes through the shared helpers in
72+
* `:libs:text-format`, so the parser is the one judge of what a button may do.
73+
*/
74+
@Stable
75+
internal class ComposerFormatting(private val field: TextFieldState) {
76+
var stripOpen by mutableStateOf(false)
77+
var linkSheetOpen by mutableStateOf(false)
78+
79+
private val text get() = field.text.toString()
80+
private val from get() = field.selection.min
81+
private val to get() = field.selection.max
82+
83+
fun toggle(format: InlineFormat) {
84+
toggleInline(text, from, to, format, Ranges)?.let(::apply)
85+
}
86+
87+
fun toggle(format: LineFormat) = apply(toggleLine(text, from, to, format))
88+
89+
fun isActive(format: InlineFormat) = isInlineActive(text, from, to, format, Ranges)
90+
91+
fun isEnabled(format: InlineFormat) =
92+
field.selection.collapsed || isActive(format) || toggleInline(text, from, to, format, Ranges) != null
93+
94+
fun isActive(format: LineFormat) = isLineActive(text, from, to, format)
95+
96+
fun addLink(url: String) = apply(insertLink(text, from, to, url))
97+
98+
private fun apply(edit: ComposerEdit) {
99+
field.edit {
100+
replace(0, length, edit.text)
101+
selection = TextRange(edit.selectionStart, edit.selectionEnd)
102+
}
103+
}
104+
105+
/** The items the system text toolbar leads with: none until there is a selection to wrap. */
106+
fun toolbarItems(): List<ToolbarFormatItem> {
107+
if (field.selection.collapsed) return emptyList()
108+
fun inline(key: String, label: String, format: InlineFormat) =
109+
ToolbarFormatItem(key, label, isEnabled(format)) { toggle(format) }
110+
return listOf(
111+
inline("fmt_bold", "B", InlineFormat.Bold),
112+
inline("fmt_italic", "I", InlineFormat.Italic),
113+
inline("fmt_strike", "S", InlineFormat.Strike),
114+
inline("fmt_code", "</>", InlineFormat.Code),
115+
ToolbarFormatItem("fmt_link", "Link", true) { linkSheetOpen = true },
116+
)
117+
}
118+
}
119+
120+
@Composable
121+
internal fun rememberComposerFormatting(field: TextFieldState): ComposerFormatting {
122+
val formatting = remember(field) { ComposerFormatting(field) }
123+
// A cleared draft is back to one line, where the toggle is gone, so the strip goes with it.
124+
LaunchedEffect(field.text.isEmpty()) {
125+
if (field.text.isEmpty()) formatting.stripOpen = false
126+
}
127+
return formatting
128+
}
129+
130+
/** Dims the characters the parser consumed, so the markers show but recede. */
131+
@Composable
132+
internal fun rememberMarkerDimming(enabled: Boolean): OutputTransformation? {
133+
val dim = CodeTheme.colors.textSecondary
134+
return remember(enabled, dim) {
135+
if (!enabled) null else OutputTransformation {
136+
val raw = asCharSequence().toString()
137+
consumedRanges(raw, parseChatText(raw)).forEach {
138+
addStyle(SpanStyle(color = dim), it.first, it.last + 1)
139+
}
140+
}
141+
}
142+
}
143+
144+
/** The `Aa` button that opens and closes the strip. */
145+
@Composable
146+
internal fun FormatToggleButton(formatting: ComposerFormatting) {
147+
val open = formatting.stripOpen
148+
Box(
149+
modifier = Modifier
150+
.testTag("chat_format_toggle")
151+
.size(com.getcode.ui.components.chat.ChatInputDefaults.AccessorySize)
152+
.clip(CircleShape)
153+
.background(Color.White.copy(alpha = if (open) 0.25f else 0.1f), CircleShape)
154+
.clickable { formatting.stripOpen = !formatting.stripOpen },
155+
contentAlignment = Alignment.Center,
156+
) {
157+
Text(
158+
text = "Aa",
159+
style = CodeTheme.typography.textSmall,
160+
color = CodeTheme.colors.textMain,
161+
)
162+
}
163+
}
164+
165+
/** The nine buttons, docked under the composer in the keyboard's colour. */
166+
@Composable
167+
internal fun FormatStrip(formatting: ComposerFormatting, field: TextFieldState) {
168+
// Reads the field so the buttons follow the selection and the text.
169+
val context = androidx.compose.ui.platform.LocalContext.current
170+
@Suppress("UNUSED_VARIABLE") val tick = field.text to field.selection
171+
fun inline(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: InlineFormat) =
172+
FormatAction(id, icon, context.getString(res), formatting.isActive(f), formatting.isEnabled(f)) { formatting.toggle(f) }
173+
fun line(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: LineFormat) =
174+
FormatAction(id, icon, context.getString(res), formatting.isActive(f), true) { formatting.toggle(f) }
175+
com.getcode.ui.components.chat.ComposerFormatStrip(
176+
inline = listOf(
177+
inline("bold", Icons.Rounded.FormatBold, CoreR.string.description_chatFormatBold, InlineFormat.Bold),
178+
inline("italic", Icons.Rounded.FormatItalic, CoreR.string.description_chatFormatItalic, InlineFormat.Italic),
179+
inline("strike", Icons.Rounded.FormatStrikethrough, CoreR.string.description_chatFormatStrike, InlineFormat.Strike),
180+
inline("code", Icons.Rounded.Code, CoreR.string.description_chatFormatCode, InlineFormat.Code),
181+
FormatAction(
182+
id = "link",
183+
icon = Icons.Rounded.Link,
184+
description = stringResource(CoreR.string.description_chatFormatLink),
185+
selected = false,
186+
enabled = true,
187+
onClick = { formatting.linkSheetOpen = true },
188+
),
189+
),
190+
lines = listOf(
191+
line("bulleted", Icons.Rounded.FormatListBulleted, CoreR.string.description_chatFormatBulleted, LineFormat.Bullet),
192+
line("numbered", Icons.Rounded.FormatListNumbered, CoreR.string.description_chatFormatNumbered, LineFormat.Numbered),
193+
line("quote", Icons.Rounded.FormatQuote, CoreR.string.description_chatFormatQuote, LineFormat.Quote),
194+
line("codeblock", Icons.Rounded.DataObject, CoreR.string.description_chatFormatCodeBlock, LineFormat.CodeBlock),
195+
),
196+
)
197+
}
198+
199+
/** Asks for the URL, and accepts only exactly one detected link. */
200+
@OptIn(ExperimentalMaterial3Api::class)
201+
@Composable
202+
internal fun LinkSheet(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
203+
var input by remember { mutableStateOf("") }
204+
var rejected by remember { mutableStateOf(false) }
205+
ModalBottomSheet(
206+
onDismissRequest = onDismiss,
207+
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
208+
containerColor = CodeTheme.colors.background,
209+
) {
210+
Column(
211+
modifier = Modifier
212+
.fillMaxWidth()
213+
.navigationBarsPadding()
214+
.padding(horizontal = 20.dp, vertical = 12.dp),
215+
verticalArrangement = Arrangement.spacedBy(12.dp),
216+
) {
217+
Text(
218+
text = stringResource(CoreR.string.title_chatFormatLink),
219+
style = CodeTheme.typography.textLarge,
220+
color = CodeTheme.colors.textMain,
221+
)
222+
Box(
223+
Modifier
224+
.fillMaxWidth()
225+
.clip(CodeTheme.shapes.small)
226+
.background(Color.White.copy(alpha = 0.08f))
227+
.padding(14.dp),
228+
) {
229+
if (input.isEmpty()) {
230+
Text(
231+
text = stringResource(CoreR.string.hint_chatFormatLink),
232+
style = CodeTheme.typography.textMedium,
233+
color = CodeTheme.colors.textSecondary,
234+
)
235+
}
236+
BasicTextField(
237+
value = input,
238+
onValueChange = { input = it; rejected = false },
239+
modifier = Modifier.fillMaxWidth().testTag("chat_format_link_input"),
240+
singleLine = true,
241+
textStyle = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain),
242+
cursorBrush = SolidColor(CodeTheme.colors.textMain),
243+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
244+
)
245+
}
246+
if (rejected) {
247+
Text(
248+
text = stringResource(CoreR.string.error_chatFormatLink),
249+
style = CodeTheme.typography.textSmall,
250+
color = com.getcode.theme.TextError,
251+
modifier = Modifier.testTag("chat_format_link_error"),
252+
)
253+
}
254+
Box(
255+
modifier = Modifier
256+
.testTag("chat_format_link_add")
257+
.fillMaxWidth()
258+
.clip(CodeTheme.shapes.small)
259+
.background(Color.White)
260+
.clickable {
261+
val url = maskableUrlOrNull(input)
262+
if (url == null) rejected = true else onConfirm(url)
263+
}
264+
.padding(vertical = 14.dp),
265+
contentAlignment = Alignment.Center,
266+
) {
267+
Text(
268+
text = stringResource(CoreR.string.action_chatFormatLinkAdd),
269+
style = CodeTheme.typography.textMedium,
270+
color = Color.Black,
271+
)
272+
}
273+
}
274+
}
275+
}

0 commit comments

Comments
 (0)