mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(chat): quiet the chat screen down
The chat screen put its color in the wrong places: my own bubbles were a
45% purple slab, every bubble carried a green "✓✓ 6/6" and a long relative
time, and each author line was a picture with trust scores plus a bold
hashed name inside the bubble.
- My bubbles: chatBubbleMe drops to 24% (dark) / 20% (light), a muted
tint that carries white text.
- Footer: clock time ("9:08", "Sep 28, 9:08") instead of "32 min. ago";
delivered ticks in the caption gray; group DMs show the k/n count only
while someone is still missing the message. Red stays for failures.
- Authors: the face moves outside the bubble, beside the last bubble of
each run (ChatBubbleLayout.authorAvatar, ChatAuthorAvatar), without
score or follow badges; the name inside is a 13sp semibold label in a
softer hash color. Applies to NIP-17/NIP-28/NIP-29 chats and cordn.
- Rhythm: 2dp between bubbles of a run, ~10dp between runs; bubbles cap
at 78% of the row.
- Header: two overlapping faces (ChatRoomFaces), semibold title and an
"N members" subtitle for group DMs.
- Rename: a subject change is a centered caption ("You renamed the chat
to “X”"), shown only when the subject actually changes; day dividers
are caption type instead of bold.
- Reaction chips: a background-colored ring instead of an outline.
- Composer: "Message" placeholder in chat composers; the send button is
a filled accent disc once there is something to send.
- ChatScreenRenderTest renders the screen offscreen (dark | light) to
commonsUI/build/chat-screen/chat.png; SubjectChangeTest covers the
rename detection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113Y3NP1fRnbQJyrH6D6YQj
This commit is contained in:
+20
-24
@@ -51,7 +51,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -59,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.chats.ui.CHAT_GROUP_WINDOW_SECONDS
|
|||||||
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
|
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize
|
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize
|
||||||
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
|
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
|
||||||
@@ -100,6 +98,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
|
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
|
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ActionTile
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ActionTile
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorAvatar
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorNameSize
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorNameWeight
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.MoreActionsToggle
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.MoreActionsToggle
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip
|
||||||
@@ -256,6 +257,13 @@ internal fun CordnMessageRow(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) },
|
drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) },
|
||||||
|
// Everyone else's face sits beside their run, as in every other group chat.
|
||||||
|
authorAvatar =
|
||||||
|
if (isMine) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{ ChatAuthorAvatar(message.envelope.pubKey, accountViewModel) { nav.nav(Route.Profile(message.envelope.pubKey)) } }
|
||||||
|
},
|
||||||
) { bgColor ->
|
) { bgColor ->
|
||||||
CordnBubbleContents(
|
CordnBubbleContents(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -356,10 +364,9 @@ private fun CordnBubbleContents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name and face on the first bubble of a burst, in the shared chat author layout — so a
|
* Name on the first bubble of a burst, styled like a DM sender's, colour included — the
|
||||||
* cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor]
|
* face sits beside the burst ([ChatAuthorAvatar]). [authorNameColorFor] derives a stable
|
||||||
* derives a stable hue from the pubkey, which is what makes authors scannable in a
|
* hue from the pubkey, which is what makes authors scannable in a fast-moving room.
|
||||||
* fast-moving room.
|
|
||||||
*
|
*
|
||||||
* [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a
|
* [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a
|
||||||
* sender up for a display name is safe: a profile is public relay data the cache already
|
* sender up for a display name is safe: a profile is public relay data the cache already
|
||||||
@@ -375,24 +382,13 @@ private fun CordnAuthorLine(
|
|||||||
val isLightTheme = MaterialTheme.colorScheme.isLight
|
val isLightTheme = MaterialTheme.colorScheme.isLight
|
||||||
val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) }
|
val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) }
|
||||||
|
|
||||||
UserDisplayNameLayout(
|
Text(
|
||||||
picture = {
|
text = name,
|
||||||
UserPicture(
|
color = nameColor,
|
||||||
userHex = pubKey,
|
fontWeight = ChatAuthorNameWeight,
|
||||||
size = Size20dp,
|
fontSize = ChatAuthorNameSize,
|
||||||
accountViewModel = accountViewModel,
|
maxLines = 1,
|
||||||
nav = nav,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
|
||||||
},
|
|
||||||
name = {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
color = nameColor,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,9 @@
|
|||||||
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
|
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
|
||||||
<string name="chat_preview_decrypting">Decrypting…</string>
|
<string name="chat_preview_decrypting">Decrypting…</string>
|
||||||
<string name="chat_preview_you_prefix">You: %1$s</string>
|
<string name="chat_preview_you_prefix">You: %1$s</string>
|
||||||
|
<string name="chat_subject_changed_by">%1$s renamed the chat to “%2$s”</string>
|
||||||
|
<string name="chat_subject_changed_by_you">You renamed the chat to “%1$s”</string>
|
||||||
|
<string name="chat_composer_hint">Message</string>
|
||||||
<string name="chat_preview_photo">Photo</string>
|
<string name="chat_preview_photo">Photo</string>
|
||||||
<string name="chat_preview_video">Video</string>
|
<string name="chat_preview_video">Video</string>
|
||||||
<string name="chat_preview_note">Shared a note</string>
|
<string name="chat_preview_note">Shared a note</string>
|
||||||
@@ -4316,6 +4319,10 @@
|
|||||||
<string name="marmot_group_disbanded_toast">Group disbanded</string>
|
<string name="marmot_group_disbanded_toast">Group disbanded</string>
|
||||||
<string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string>
|
<string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string>
|
||||||
<string name="marmot_group_info_updated">Group info updated</string>
|
<string name="marmot_group_info_updated">Group info updated</string>
|
||||||
|
<plurals name="chat_room_member_count">
|
||||||
|
<item quantity="one">%1$d member</item>
|
||||||
|
<item quantity="other">%1$d members</item>
|
||||||
|
</plurals>
|
||||||
<plurals name="marmot_member_count">
|
<plurals name="marmot_member_count">
|
||||||
<item quantity="one">%1$d member</item>
|
<item quantity="one">%1$d member</item>
|
||||||
<item quantity="other">%1$d members</item>
|
<item quantity="other">%1$d members</item>
|
||||||
|
|||||||
+16
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -103,6 +104,10 @@ private val ChatChipOverlapArrangement = Arrangement.spacedBy((-12).dp)
|
|||||||
// already has 6dp bottom padding; the chips poke ~12dp in, so ~8dp more clears them.
|
// already has 6dp bottom padding; the chips poke ~12dp in, so ~8dp more clears them.
|
||||||
private val ChatChipOverlapReserve = 8.dp
|
private val ChatChipOverlapReserve = 8.dp
|
||||||
|
|
||||||
|
// The column the author face sits in, beside a group chat's bubbles, and its gap to them.
|
||||||
|
private val ChatAuthorGutterModifier = Modifier.width(28.dp)
|
||||||
|
private val ChatAuthorGutterGap = Modifier.width(6.dp)
|
||||||
|
|
||||||
// Swipe-to-reply: releasing past the threshold fires the reply; the bubble never
|
// Swipe-to-reply: releasing past the threshold fires the reply; the bubble never
|
||||||
// drags further than the max.
|
// drags further than the max.
|
||||||
private val SwipeReplyThreshold = 56.dp
|
private val SwipeReplyThreshold = 56.dp
|
||||||
@@ -133,6 +138,10 @@ fun ChatBubbleLayout(
|
|||||||
// status glyphs). Null skips it entirely.
|
// status glyphs). Null skips it entirely.
|
||||||
footerRow: (@Composable () -> Unit)? = null,
|
footerRow: (@Composable () -> Unit)? = null,
|
||||||
drawAuthorLine: @Composable () -> Unit,
|
drawAuthorLine: @Composable () -> Unit,
|
||||||
|
// The author's face in a group chat, drawn OUTSIDE the bubble, beside the last bubble of
|
||||||
|
// the author's run (the other bubbles of the run keep its width as an indent so the run
|
||||||
|
// lines up). Null for one-on-one chats, own messages and quotes: no gutter at all.
|
||||||
|
authorAvatar: (@Composable () -> Unit)? = null,
|
||||||
inner: @Composable (MutableState<Color>) -> Unit,
|
inner: @Composable (MutableState<Color>) -> Unit,
|
||||||
) {
|
) {
|
||||||
val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe
|
val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe
|
||||||
@@ -306,7 +315,14 @@ fun ChatBubbleLayout(
|
|||||||
Row(
|
Row(
|
||||||
modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier),
|
modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier),
|
||||||
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
|
if (authorAvatar != null && !isLoggedInUser && !innerQuote) {
|
||||||
|
Box(ChatAuthorGutterModifier) {
|
||||||
|
if (groupPosition.isLastOfGroup) authorAvatar()
|
||||||
|
}
|
||||||
|
Spacer(ChatAuthorGutterGap)
|
||||||
|
}
|
||||||
InnerChatBubble(
|
InnerChatBubble(
|
||||||
isLoggedInUser = isLoggedInUser,
|
isLoggedInUser = isLoggedInUser,
|
||||||
innerQuote = innerQuote,
|
innerQuote = innerQuote,
|
||||||
|
|||||||
+33
-4
@@ -21,8 +21,11 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.chats.ui
|
package com.vitorpamplona.amethyst.commons.chats.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.DividerDefaults
|
import androidx.compose.material3.DividerDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -30,11 +33,19 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
|
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP
|
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day (or status) divider between chat messages: hairlines either side of a caption-sized
|
||||||
|
* label. Caption type, not bold: it marks where the conversation crossed a day, it is not a
|
||||||
|
* heading the eye should land on before the messages.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatDivisor(
|
fun ChatDivisor(
|
||||||
info: String,
|
info: String,
|
||||||
@@ -53,9 +64,9 @@ fun ChatDivisor(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = info,
|
text = info,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = Font14SP,
|
fontSize = Font12SP,
|
||||||
color = color,
|
color = if (color.isSpecified) color else MaterialTheme.colorScheme.placeholderText,
|
||||||
modifier = HalfPadding,
|
modifier = HalfPadding,
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -65,3 +76,21 @@ fun ChatDivisor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something that happened to the conversation rather than a message in it (a rename): one
|
||||||
|
* centered line in caption type, no bubble and no rules, so it never competes with what
|
||||||
|
* people said.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChatSystemCaption(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = Font12SP,
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = ChatSystemCaptionModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ChatSystemCaptionModifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 10.dp)
|
||||||
|
|||||||
+43
-11
@@ -21,19 +21,33 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.chats.ui
|
package com.vitorpamplona.amethyst.commons.chats.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_subject_changed_by
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_subject_changed_by_you
|
||||||
import com.vitorpamplona.amethyst.commons.resources.never
|
import com.vitorpamplona.amethyst.commons.resources.never
|
||||||
import com.vitorpamplona.amethyst.commons.resources.today
|
import com.vitorpamplona.amethyst.commons.resources.today
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
|
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quiet rows between messages: a day divider when the date changes, and a one-line caption
|
||||||
|
* when a message renames the conversation ("Alice renamed the chat to “Product”").
|
||||||
|
*
|
||||||
|
* The rename used to be a bold divider holding just the new name, which read like a section
|
||||||
|
* heading and said nothing about what happened. Now it says who did what, in caption type.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NewDateOrSubjectDivisor(
|
fun NewDateOrSubjectDivisor(
|
||||||
previous: Note?,
|
previous: Note?,
|
||||||
note: Note,
|
note: Note,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
if (previous == null) return
|
if (previous == null) return
|
||||||
|
|
||||||
@@ -43,17 +57,35 @@ fun NewDateOrSubjectDivisor(
|
|||||||
val prevDate = remember(previous) { dateFormatter(previous.event?.createdAt, never, today) }
|
val prevDate = remember(previous) { dateFormatter(previous.event?.createdAt, never, today) }
|
||||||
val date = remember(note) { dateFormatter(note.event?.createdAt, never, today) }
|
val date = remember(note) { dateFormatter(note.event?.createdAt, never, today) }
|
||||||
|
|
||||||
val subject = remember(note) { note.event?.subject() }
|
|
||||||
|
|
||||||
if (prevDate != date) {
|
if (prevDate != date) {
|
||||||
if (subject != null) {
|
ChatDivisor(date)
|
||||||
ChatDivisor("$date - $subject")
|
}
|
||||||
} else {
|
|
||||||
ChatDivisor(date)
|
val newSubject = remember(previous, note) { subjectChangeOf(previous.event, note.event) }
|
||||||
}
|
if (newSubject != null) {
|
||||||
} else {
|
val author = note.author
|
||||||
if (subject != null) {
|
val text =
|
||||||
ChatDivisor(subject)
|
when {
|
||||||
}
|
author == null -> newSubject
|
||||||
|
accountViewModel.isLoggedUser(author) -> stringRes(Res.string.chat_subject_changed_by_you, newSubject)
|
||||||
|
else -> {
|
||||||
|
val name by observeUserName(author, accountViewModel)
|
||||||
|
stringRes(Res.string.chat_subject_changed_by, name, newSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatSystemCaption(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subject [newer] sets, or null when it sets none or repeats the one [older] already carried.
|
||||||
|
* Some clients tag every message of a named group with its subject; without the repeat check each
|
||||||
|
* of those would announce a rename.
|
||||||
|
*/
|
||||||
|
fun subjectChangeOf(
|
||||||
|
older: Event?,
|
||||||
|
newer: Event?,
|
||||||
|
): String? {
|
||||||
|
val subject = newer?.subject()?.trim()?.ifBlank { null } ?: return null
|
||||||
|
return if (older?.subject()?.trim() == subject) null else subject
|
||||||
|
}
|
||||||
|
|||||||
+36
-5
@@ -20,16 +20,29 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chats.ui
|
package com.vitorpamplona.amethyst.commons.chats.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.accessibility_send
|
import com.vitorpamplona.amethyst.commons.resources.accessibility_send
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer's send button. Idle it is a quiet glyph; once there is something to send it
|
||||||
|
* becomes a filled accent disc, so the one action the screen is waiting for is the thing
|
||||||
|
* that stands out, and an outline-gray arrow no longer looks disabled when it is not.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ThinSendButton(
|
fun ThinSendButton(
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
@@ -41,10 +54,28 @@ fun ThinSendButton(
|
|||||||
// modifier = modifier,
|
// modifier = modifier,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isActive) {
|
||||||
symbol = MaterialSymbols.AutoMirrored.Send,
|
Box(
|
||||||
contentDescription = stringRes(id = Res.string.accessibility_send),
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Size25Modifier,
|
modifier = SendDiscModifier.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.AutoMirrored.Send,
|
||||||
|
contentDescription = stringRes(id = Res.string.accessibility_send),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = SendGlyphModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.AutoMirrored.Send,
|
||||||
|
contentDescription = stringRes(id = Res.string.accessibility_send),
|
||||||
|
tint = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
modifier = Size25Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SendDiscModifier = Modifier.size(36.dp)
|
||||||
|
private val SendGlyphModifier = Modifier.size(19.dp)
|
||||||
|
|||||||
+15
-9
@@ -358,7 +358,6 @@ private fun RenderDeliveryTicks(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val pendingColor = MaterialTheme.colorScheme.placeholderText
|
val pendingColor = MaterialTheme.colorScheme.placeholderText
|
||||||
val deliveredColor = MaterialTheme.colorScheme.allGoodColor
|
|
||||||
|
|
||||||
// The send's own lifecycle outranks relay acceptance: until the event has
|
// The send's own lifecycle outranks relay acceptance: until the event has
|
||||||
// been handed to the relay pool there is nothing for a relay to have
|
// been handed to the relay pool there is nothing for a relay to have
|
||||||
@@ -398,19 +397,23 @@ private fun RenderDeliveryTicks(
|
|||||||
// the sender's own self-copy wrap only shows in the detail dialog.
|
// the sender's own self-copy wrap only shows in the detail dialog.
|
||||||
val others = delivery.otherRecipients
|
val others = delivery.otherRecipients
|
||||||
if (others != null && others.size > 1) {
|
if (others != null && others.size > 1) {
|
||||||
// Group DM: double check once everyone got it, plus a delivered count.
|
// Group DM: a double check once everyone got it. The k/n count only shows while
|
||||||
|
// someone is still missing it: "6/6" on every bubble restated the double check in a
|
||||||
|
// bright color, while "4/6" is the one case worth reading.
|
||||||
val deliveredCount = others.count { it.isDelivered }
|
val deliveredCount = others.count { it.isDelivered }
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
DeliveryLadderTick(
|
DeliveryLadderTick(
|
||||||
pending = deliveredCount == 0 && !seenSomewhere,
|
pending = deliveredCount == 0 && !seenSomewhere,
|
||||||
fullyAccepted = delivery.isFullyAccepted,
|
fullyAccepted = delivery.isFullyAccepted,
|
||||||
)
|
)
|
||||||
Text(
|
if (!delivery.isFullyAccepted) {
|
||||||
text = "$deliveredCount/${others.size}",
|
Text(
|
||||||
fontSize = Font12SP,
|
text = "$deliveredCount/${others.size}",
|
||||||
color = if (delivery.isFullyAccepted) deliveredColor else pendingColor,
|
fontSize = Font12SP,
|
||||||
maxLines = 1,
|
color = pendingColor,
|
||||||
)
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -433,8 +436,11 @@ private fun DeliveryLadderTick(
|
|||||||
pending ->
|
pending ->
|
||||||
TickIcon(MaterialSymbols.Schedule, Res.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText)
|
TickIcon(MaterialSymbols.Schedule, Res.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText)
|
||||||
|
|
||||||
|
// Same caption gray as the time beside it: the single vs double check already says
|
||||||
|
// how far it got, and a green glyph on every bubble was a third accent on the screen.
|
||||||
|
// Color is kept for the states that need the reader: rejected and failed.
|
||||||
fullyAccepted ->
|
fullyAccepted ->
|
||||||
TickIcon(MaterialSymbols.DoneAll, Res.string.chat_delivery_delivered_all, MaterialTheme.colorScheme.allGoodColor)
|
TickIcon(MaterialSymbols.DoneAll, Res.string.chat_delivery_delivered_all, MaterialTheme.colorScheme.placeholderText)
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
TickIcon(MaterialSymbols.Done, Res.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText)
|
TickIcon(MaterialSymbols.Done, Res.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText)
|
||||||
|
|||||||
+1
-1
@@ -269,7 +269,7 @@ fun ChatFeedLoaded(
|
|||||||
// before the bubble. Composing it after put the header below its own message —
|
// before the bubble. Composing it after put the header below its own message —
|
||||||
// i.e. visually heading the NEXT (newer) message while showing this one's date,
|
// i.e. visually heading the NEXT (newer) message while showing this one's date,
|
||||||
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
|
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
|
||||||
NewDateOrSubjectDivisor(older, item)
|
NewDateOrSubjectDivisor(older, item, accountViewModel)
|
||||||
|
|
||||||
// Per-relay paging markers for the gap toward the next-older message. Older items sit
|
// Per-relay paging markers for the gap toward the next-older message. Older items sit
|
||||||
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
|
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
|
||||||
|
|||||||
+12
@@ -386,6 +386,18 @@ fun NormalChatNote(
|
|||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
// Group chats put each author's face beside their run; one-on-one chats and my own
|
||||||
|
// messages have none (drawAuthorInfo is already false for both).
|
||||||
|
authorAvatar =
|
||||||
|
if (drawAuthorInfo && !isLoggedInUser && !innerQuote) {
|
||||||
|
{
|
||||||
|
note.author?.let { author ->
|
||||||
|
ChatAuthorAvatar(author.pubkeyHex, accountViewModel) { nav.nav(routeFor(author)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
) { bgColor ->
|
) { bgColor ->
|
||||||
MessageBubbleLines(
|
MessageBubbleLines(
|
||||||
note,
|
note,
|
||||||
|
|||||||
+13
-12
@@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor
|
import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
|
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
|
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
@@ -212,6 +211,15 @@ fun ChatReactionChips(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edge of an engagement chip: a ring in the screen's background color rather than a
|
||||||
|
* hairline. Riding the bubble's border, the ring cuts the chip out of the bubble cleanly;
|
||||||
|
* the outlined pill it replaces drew one more line on every bubble that had a reaction.
|
||||||
|
* A chip I'm part of says so with its accent fill alone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun chatChipRing(): BorderStroke = BorderStroke(2.dp, MaterialTheme.colorScheme.background)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The strip of engagement chips that rides a chat bubble's bottom border. Shared so
|
* The strip of engagement chips that rides a chat bubble's bottom border. Shared so
|
||||||
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from
|
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from
|
||||||
@@ -276,7 +284,7 @@ private fun MinichatChip(
|
|||||||
Surface(
|
Surface(
|
||||||
shape = ButtonBorder,
|
shape = ButtonBorder,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
|
border = chatChipRing(),
|
||||||
// Plain clickable (not the Surface onClick overload) so the chip keeps its
|
// Plain clickable (not the Surface onClick overload) so the chip keeps its
|
||||||
// content height instead of being padded to the 48dp minimum touch target,
|
// content height instead of being padded to the 48dp minimum touch target,
|
||||||
// which would drop it below the bubble border the reaction chips ride.
|
// which would drop it below the bubble border the reaction chips ride.
|
||||||
@@ -316,17 +324,10 @@ fun ReactionChipView(
|
|||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
val border =
|
|
||||||
if (chip.includesMe) {
|
|
||||||
BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.60f))
|
|
||||||
} else {
|
|
||||||
BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = ButtonBorder,
|
shape = ButtonBorder,
|
||||||
color = background,
|
color = background,
|
||||||
border = border,
|
border = chatChipRing(),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clip(ButtonBorder)
|
.clip(ButtonBorder)
|
||||||
@@ -355,7 +356,7 @@ private fun ZapChip(
|
|||||||
Surface(
|
Surface(
|
||||||
shape = ButtonBorder,
|
shape = ButtonBorder,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
|
border = chatChipRing(),
|
||||||
// Plain clickable (not the Surface onClick overload) so the chip keeps its
|
// Plain clickable (not the Surface onClick overload) so the chip keeps its
|
||||||
// content height instead of being padded to the 48dp minimum touch target,
|
// content height instead of being padded to the 48dp minimum touch target,
|
||||||
// which would drop it below the bubble border the reaction chips ride.
|
// which would drop it below the bubble border the reaction chips ride.
|
||||||
@@ -385,7 +386,7 @@ private fun PendingZapChip() {
|
|||||||
Surface(
|
Surface(
|
||||||
shape = ButtonBorder,
|
shape = ButtonBorder,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
|
border = chatChipRing(),
|
||||||
) {
|
) {
|
||||||
ChipContentRow {
|
ChipContentRow {
|
||||||
ZappedIcon(Size14Modifier)
|
ZappedIcon(Size14Modifier)
|
||||||
|
|||||||
+12
-9
@@ -41,8 +41,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_d
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayLocation
|
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayLocation
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayPoW
|
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayPoW
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
|
import com.vitorpamplona.amethyst.commons.ui.note.timeAbsoluteNoDot
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
|
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
|
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
|
||||||
@@ -54,17 +53,21 @@ import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
|
|||||||
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
|
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time on a chat bubble, as a clock time ("9:08"; "Sep 28, 9:08" once it is not today).
|
||||||
|
*
|
||||||
|
* Not relative: "32 min. ago" on every bubble was long, kept changing under the reader, and
|
||||||
|
* made a column of near-identical captions. A conversation is read by when things were said.
|
||||||
|
* The chat time is wrapped in a tap target that opens the relay/delivery dialog, so it is
|
||||||
|
* plain text with no toggle of its own.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatTimeAgo(baseNote: Note) {
|
fun ChatTimeAgo(baseNote: Note) {
|
||||||
ToggleableTimeAgoText(
|
Text(
|
||||||
timestamp = baseNote.createdAt() ?: 0L,
|
text = timeAbsoluteNoDot(baseNote.createdAt() ?: 0L),
|
||||||
style = TimeAgoStyle.Short,
|
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
// The chat time is wrapped in a tap target that opens the relay/delivery dialog,
|
maxLines = 1,
|
||||||
// so it must not steal the tap to toggle relative/absolute. The absolute time is
|
|
||||||
// shown in that dialog instead.
|
|
||||||
toggleable = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-46
@@ -20,20 +20,23 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed
|
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
||||||
import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName
|
import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
@@ -42,17 +45,19 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.FollowingIcon
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.InnerUserPicture
|
import com.vitorpamplona.amethyst.commons.ui.note.InnerUserPicture
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.ObserveAndRenderUserCards
|
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.WatchUserFollows
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
|
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author's name at the top of the first bubble of their run. Name only: the face sits
|
||||||
|
* outside the bubble, beside the run's last bubble ([ChatAuthorAvatar]), so the bubble keeps
|
||||||
|
* to what was said instead of opening with a picture and its badges.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawAuthorInfo(
|
fun DrawAuthorInfo(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
@@ -77,7 +82,9 @@ fun DrawAuthorInfo(
|
|||||||
/**
|
/**
|
||||||
* A stable, pubkey-derived name color so authors are scannable in fast-moving
|
* A stable, pubkey-derived name color so authors are scannable in fast-moving
|
||||||
* group rooms. The hue comes from the pubkey; saturation/lightness are tuned per
|
* group rooms. The hue comes from the pubkey; saturation/lightness are tuned per
|
||||||
* theme so every hue stays readable on the "them" bubble fill.
|
* theme so every hue stays readable on the "them" bubble fill. Saturation is kept
|
||||||
|
* low: the hue only has to tell two names apart, and at full strength every author
|
||||||
|
* line was another bright color competing with the message under it.
|
||||||
*/
|
*/
|
||||||
fun authorNameColorFor(
|
fun authorNameColorFor(
|
||||||
pubkeyHex: String,
|
pubkeyHex: String,
|
||||||
@@ -85,9 +92,39 @@ fun authorNameColorFor(
|
|||||||
): Color {
|
): Color {
|
||||||
val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat()
|
val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat()
|
||||||
return if (isLightTheme) {
|
return if (isLightTheme) {
|
||||||
Color.hsl(hue, saturation = 0.70f, lightness = 0.35f)
|
Color.hsl(hue, saturation = 0.45f, lightness = 0.38f)
|
||||||
} else {
|
} else {
|
||||||
Color.hsl(hue, saturation = 0.55f, lightness = 0.70f)
|
Color.hsl(hue, saturation = 0.38f, lightness = 0.74f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Size of the author face beside a chat bubble. */
|
||||||
|
val ChatAuthorAvatarSize = 28.dp
|
||||||
|
|
||||||
|
/** Weight and size of the author name inside a bubble: a label over the message, not a heading. */
|
||||||
|
val ChatAuthorNameWeight = FontWeight.SemiBold
|
||||||
|
val ChatAuthorNameSize = 13.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author's face beside the last bubble of their run in a group chat. Plain picture: the
|
||||||
|
* trust score and follow mark it used to carry inside the bubble were unreadable at this size
|
||||||
|
* and live on the profile one tap away.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChatAuthorAvatar(
|
||||||
|
userHex: HexKey,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
LoadUser(baseUserHex = userHex) { user ->
|
||||||
|
val userState = user?.let { observeUserInfo(it, accountViewModel).value }
|
||||||
|
InnerUserPicture(
|
||||||
|
userHex = userHex,
|
||||||
|
userPicture = userState?.info?.picture,
|
||||||
|
userName = userState?.info?.bestName(),
|
||||||
|
size = ChatAuthorAvatarSize,
|
||||||
|
modifier = Modifier.clip(CircleShape).clickable(onClick = onClick),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,41 +153,21 @@ private fun WatchAndDisplayUser(
|
|||||||
authorNameColorFor(author.pubkeyHex, isLightTheme)
|
authorNameColorFor(author.pubkeyHex, isLightTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
UserDisplayNameLayout(
|
Row(
|
||||||
picture = {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
InnerUserPicture(
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
userHex = author.pubkeyHex,
|
) {
|
||||||
userPicture = userState?.info?.picture,
|
CreateTextWithEmoji(
|
||||||
userName = displayName,
|
text = displayName ?: author.pubkeyDisplayHex(),
|
||||||
size = Size20dp,
|
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
||||||
modifier = Modifier,
|
color = nameColor,
|
||||||
)
|
maxLines = 1,
|
||||||
|
fontWeight = ChatAuthorNameWeight,
|
||||||
WatchUserFollows(author.pubkeyHex, accountViewModel) { newFollowingState ->
|
fontSize = ChatAuthorNameSize,
|
||||||
if (newFollowingState) {
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
FollowingIcon(Size5Modifier)
|
)
|
||||||
}
|
// An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
|
||||||
}
|
// unless its contextual name already does.
|
||||||
|
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
|
||||||
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel)
|
}
|
||||||
},
|
|
||||||
name = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
CreateTextWithEmoji(
|
|
||||||
text = displayName ?: author.pubkeyDisplayHex(),
|
|
||||||
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
|
||||||
color = nameColor,
|
|
||||||
maxLines = 1,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
// An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
|
|
||||||
// unless its contextual name already does.
|
|
||||||
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
|
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
|
||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite
|
import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite
|
||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding
|
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding
|
||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving
|
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving
|
||||||
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body
|
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body
|
||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title
|
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset
|
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset
|
||||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
|
||||||
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
|
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
|
||||||
@@ -286,7 +286,7 @@ fun MarmotGroupMessageComposer(
|
|||||||
shape = EditFieldBorder,
|
shape = EditFieldBorder,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(Res.string.reply_here),
|
text = stringRes(Res.string.chat_composer_hint),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-6
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header
|
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -51,15 +52,17 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.call_video
|
import com.vitorpamplona.amethyst.commons.resources.call_video
|
||||||
import com.vitorpamplona.amethyst.commons.resources.call_voice
|
import com.vitorpamplona.amethyst.commons.resources.call_voice
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_room_member_count
|
||||||
import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata
|
import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata
|
||||||
import com.vitorpamplona.amethyst.commons.resources.messages_group_descriptor
|
import com.vitorpamplona.amethyst.commons.resources.messages_group_descriptor
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
|
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
|
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
|
import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
|
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
|
||||||
@@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
@@ -92,7 +96,7 @@ fun RenderRoomTopBar(
|
|||||||
|
|
||||||
Spacer(modifier = DoubleHorzSpacer)
|
Spacer(modifier = DoubleHorzSpacer)
|
||||||
|
|
||||||
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
|
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.SemiBold, accountViewModel = accountViewModel)
|
||||||
|
|
||||||
if (onVideoCallClick != null) {
|
if (onVideoCallClick != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -141,13 +145,26 @@ fun RenderRoomTopBar(
|
|||||||
TopBarExtensibleWithBackButton(
|
TopBarExtensibleWithBackButton(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
NonClickableUserPictures(
|
// Two overlapping faces without badges, as on the Messages list: a 2x2
|
||||||
room = room,
|
// mosaic with a trust score on each face is unreadable at header size.
|
||||||
accountViewModel = accountViewModel,
|
ChatRoomFaces(
|
||||||
|
userHexList = remember(room) { room.users.toList() },
|
||||||
size = Size34dp,
|
size = Size34dp,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel)
|
// The name, then who is in it: a group header with only a name gave no
|
||||||
|
// hint of how many people would read what you type.
|
||||||
|
Column(Modifier.padding(start = 10.dp).weight(1f)) {
|
||||||
|
RoomNameOnlyDisplay(room, Modifier, FontWeight.SemiBold, accountViewModel)
|
||||||
|
Text(
|
||||||
|
// room.users is everyone but me.
|
||||||
|
text = pluralStringRes(Res.plurals.chat_room_member_count, room.users.size + 1, room.users.size + 1),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (onVideoCallClick != null) {
|
if (onVideoCallClick != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
+2
-2
@@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
|
|||||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
|
||||||
import com.vitorpamplona.amethyst.commons.resources.recipient_missing_dm_relays
|
import com.vitorpamplona.amethyst.commons.resources.recipient_missing_dm_relays
|
||||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
|
||||||
import com.vitorpamplona.amethyst.commons.resources.retry_without_encryption
|
import com.vitorpamplona.amethyst.commons.resources.retry_without_encryption
|
||||||
import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_in
|
import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_in
|
||||||
import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning
|
import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning
|
||||||
@@ -226,7 +226,7 @@ fun EditField(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(Res.string.reply_here),
|
text = stringRes(Res.string.chat_composer_hint),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggesti
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.back
|
import com.vitorpamplona.amethyst.commons.resources.back
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
|
||||||
import com.vitorpamplona.amethyst.commons.resources.concord_banned_notice
|
import com.vitorpamplona.amethyst.commons.resources.concord_banned_notice
|
||||||
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
|
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
|
||||||
import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_cut
|
import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_cut
|
||||||
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.resources.concord_timer_active
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
|
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
|
||||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
|
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
|
||||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_two
|
import com.vitorpamplona.amethyst.commons.resources.concord_typing_two
|
||||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
|
||||||
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
|
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
|
||||||
@@ -589,7 +589,7 @@ private fun ConcordMessageComposer(
|
|||||||
shape = EditFieldBorder,
|
shape = EditFieldBorder,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(Res.string.reply_here),
|
text = stringRes(Res.string.chat_composer_hint),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggesti
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_editing_banner
|
import com.vitorpamplona.amethyst.commons.resources.buzz_editing_banner
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
|
||||||
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
|
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
|
||||||
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
||||||
@@ -199,7 +199,7 @@ fun EditFieldRow(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(Res.string.reply_here),
|
text = stringRes(Res.string.chat_composer_hint),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
|
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
|
||||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
|
||||||
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
|
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
|
||||||
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
|
||||||
@@ -140,7 +140,7 @@ fun NestEditFieldRow(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(Res.string.reply_here),
|
text = stringRes(Res.string.chat_composer_hint),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-4
@@ -420,11 +420,13 @@ val ColorScheme.subtleBorder: Color
|
|||||||
val ColorScheme.chatBackground: Color
|
val ColorScheme.chatBackground: Color
|
||||||
get() = if (isLight) LightChatBackground else DarkChatBackground
|
get() = if (isLight) LightChatBackground else DarkChatBackground
|
||||||
|
|
||||||
// Accent-following bubble fill for the logged-in user's own chat messages. Stronger
|
// Accent-following bubble fill for the logged-in user's own chat messages. A tint, not a
|
||||||
// than mediumImportanceLink so "mine" vs "theirs" vs background read at a glance
|
// fill: enough accent that "mine" vs "theirs" reads at a glance, low enough that a screen
|
||||||
// while the default onBackground text stays readable on top of it.
|
// of my own messages is a deep muted tone carrying white text, not a wall of purple (at
|
||||||
|
// 45% the bubbles were the loudest thing on the screen and the gray captions on them
|
||||||
|
// were hard to read).
|
||||||
val ColorScheme.chatBubbleMe: Color
|
val ColorScheme.chatBubbleMe: Color
|
||||||
get() = primary.copy(alpha = if (isLight) 0.36f else 0.45f)
|
get() = primary.copy(alpha = if (isLight) 0.20f else 0.24f)
|
||||||
|
|
||||||
val ColorScheme.chatBubbleThem: Color
|
val ColorScheme.chatBubbleThem: Color
|
||||||
get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem
|
get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem
|
||||||
|
|||||||
+9
-4
@@ -245,7 +245,9 @@ val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(15.dp)
|
|||||||
val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp)
|
val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp)
|
||||||
val ShowMoreRelaysButtonBoxModifer = Modifier.width(55.dp).height(17.dp)
|
val ShowMoreRelaysButtonBoxModifer = Modifier.width(55.dp).height(17.dp)
|
||||||
|
|
||||||
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)
|
// Caps a bubble at ~3/4 of the row: past that, a long message turns into a wide slab
|
||||||
|
// that reads like a document instead of a line of conversation.
|
||||||
|
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.78f)
|
||||||
|
|
||||||
val ModifierWidth3dp = Modifier.width(3.dp)
|
val ModifierWidth3dp = Modifier.width(3.dp)
|
||||||
|
|
||||||
@@ -272,14 +274,17 @@ val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp)
|
|||||||
val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||||
|
|
||||||
val ChatPaddingInnerQuoteModifier = Modifier
|
val ChatPaddingInnerQuoteModifier = Modifier
|
||||||
|
|
||||||
|
// Two gaps only, so the column has a rhythm: ~2dp between bubbles of one author run and
|
||||||
|
// ~10dp where a new run starts. The gap lives on top of the bubble that opens a run.
|
||||||
val ChatPaddingModifier =
|
val ChatPaddingModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(1f)
|
.fillMaxWidth(1f)
|
||||||
.padding(
|
.padding(
|
||||||
start = 12.dp,
|
start = 12.dp,
|
||||||
end = 12.dp,
|
end = 12.dp,
|
||||||
top = 3.dp,
|
top = 9.dp,
|
||||||
bottom = 3.dp,
|
bottom = 1.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tighter top gap for a message visually connected to the previous message of the
|
// Tighter top gap for a message visually connected to the previous message of the
|
||||||
@@ -291,7 +296,7 @@ val ChatPaddingGroupedModifier =
|
|||||||
start = 12.dp,
|
start = 12.dp,
|
||||||
end = 12.dp,
|
end = 12.dp,
|
||||||
top = 1.dp,
|
top = 1.dp,
|
||||||
bottom = 3.dp,
|
bottom = 1.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
val profileContentHeaderModifier =
|
val profileContentHeaderModifier =
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.chats.ui
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class SubjectChangeTest {
|
||||||
|
private val key = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||||
|
|
||||||
|
private fun message(subject: String?) =
|
||||||
|
Event(
|
||||||
|
id = key,
|
||||||
|
pubKey = key,
|
||||||
|
createdAt = 1L,
|
||||||
|
kind = 14,
|
||||||
|
tags = if (subject != null) arrayOf(arrayOf("subject", subject)) else emptyArray(),
|
||||||
|
content = "hi",
|
||||||
|
sig = key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noSubjectIsNoRename() {
|
||||||
|
assertNull(subjectChangeOf(message(null), message(null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNewSubjectIsARename() {
|
||||||
|
assertEquals("Product", subjectChangeOf(message(null), message("Product")))
|
||||||
|
assertEquals("Product", subjectChangeOf(message("Old"), message("Product")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRepeatedSubjectIsNotARename() {
|
||||||
|
// Some clients tag every message of a named group with its subject.
|
||||||
|
assertNull(subjectChangeOf(message("Product"), message("Product")))
|
||||||
|
assertNull(subjectChangeOf(message("Product"), message(" Product ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBlankSubjectIsNoRename() {
|
||||||
|
assertNull(subjectChangeOf(message("Product"), message(" ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
|
||||||
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
|
||||||
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
|
||||||
|
import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemCaption
|
||||||
|
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
|
import org.jetbrains.skia.EncodedImageFormat
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a group chat offscreen (no device, no display) through `ImageComposeScene`, in the dark and
|
||||||
|
* light themes side by side, and writes it to `commonsUI/build/chat-screen/chat.png`.
|
||||||
|
*
|
||||||
|
* Bubbles, dividers, the rename caption, reaction chips and the send button are the real shared
|
||||||
|
* composables. The header row, the bubble footer and the faces are stand-ins with the same geometry
|
||||||
|
* and styles, since the real ones need a Note, an account and the network. It exists so the chat's
|
||||||
|
* visual weight can be looked at while it is tuned, and it fails if the screen draws nothing.
|
||||||
|
*/
|
||||||
|
class ChatScreenRenderTest {
|
||||||
|
private val outDir = File("build/chat-screen").apply { mkdirs() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chat() {
|
||||||
|
val density = 2f
|
||||||
|
val widthDp = 840
|
||||||
|
val heightDp = 1040
|
||||||
|
val scene =
|
||||||
|
ImageComposeScene(
|
||||||
|
width = (widthDp * density).toInt(),
|
||||||
|
height = (heightDp * density).toInt(),
|
||||||
|
density = Density(density),
|
||||||
|
) { ThemeComparisonRow { SampleGroupChat() } }
|
||||||
|
try {
|
||||||
|
var image = scene.render(0)
|
||||||
|
repeat(SETTLE_FRAMES) { frame ->
|
||||||
|
Thread.sleep(FRAME_MILLIS)
|
||||||
|
image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
|
||||||
|
}
|
||||||
|
val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode")
|
||||||
|
File(outDir, "chat.png").writeBytes(png.bytes)
|
||||||
|
|
||||||
|
val pixels = image.peekPixels() ?: error("no pixels")
|
||||||
|
val distinct = HashSet<Int>()
|
||||||
|
for (y in 0 until image.height step 7) for (x in 0 until image.width step 7) distinct += pixels.getColor(x, y)
|
||||||
|
assertTrue(distinct.size > 20, "the chat rendered almost nothing (${distinct.size} colours)")
|
||||||
|
} finally {
|
||||||
|
scene.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SETTLE_FRAMES = 12
|
||||||
|
const val FRAME_MILLIS = 60L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val OUTPOST = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
|
||||||
|
private const val DAVID = "3f9e2d1c0b0a99887766554433221100ffeeddccbbaa99887766554433221100"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Face(
|
||||||
|
color: Color,
|
||||||
|
size: Int = 28,
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(size.dp).background(color, CircleShape))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same geometry as `ChatRoomFaces` (two overlapping faces with a background ring). */
|
||||||
|
@Composable
|
||||||
|
private fun HeaderFaces() {
|
||||||
|
val face = 34.dp * 0.68f
|
||||||
|
Box(Modifier.size(34.dp)) {
|
||||||
|
Box(Modifier.align(Alignment.TopStart).size(face).background(Color(0xFF8C6E5A), CircleShape))
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).size(face + 4.dp).background(MaterialTheme.colorScheme.background, CircleShape),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(face).background(Color(0xFF4F86C6), CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header() {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
HeaderFaces()
|
||||||
|
Column(Modifier.padding(start = 10.dp)) {
|
||||||
|
Text("NosFabrica Product", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("5 members", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same type and color as `ChatTimeAgo` + the delivered tick of `ChatTimeWithDelivery`. */
|
||||||
|
@Composable
|
||||||
|
private fun Footer(
|
||||||
|
time: String,
|
||||||
|
mine: Boolean,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(time, color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP)
|
||||||
|
if (mine) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(MaterialSymbols.DoneAll, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bubble(
|
||||||
|
text: String,
|
||||||
|
mine: Boolean,
|
||||||
|
position: ChatGroupPosition = ChatGroupPosition.SINGLE,
|
||||||
|
time: String? = null,
|
||||||
|
author: Pair<String, String>? = null,
|
||||||
|
faceColor: Color = Color.Gray,
|
||||||
|
fire: Boolean = false,
|
||||||
|
) {
|
||||||
|
val isLight = MaterialTheme.colorScheme.isLight
|
||||||
|
ChatBubbleLayout(
|
||||||
|
isLoggedInUser = mine,
|
||||||
|
isDraft = false,
|
||||||
|
innerQuote = false,
|
||||||
|
drawAuthorInfo = author != null && position.isFirstOfGroup,
|
||||||
|
groupPosition = position,
|
||||||
|
onClick = { false },
|
||||||
|
onAuthorClick = {},
|
||||||
|
actionMenu = {},
|
||||||
|
reactionsRow =
|
||||||
|
if (fire) {
|
||||||
|
{ ChatChipFlowRow { ReactionChipView(ReactionChip("🔥", 1, false), {}, {}) } }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
footerRow = time?.let { { Footer(it, mine) } },
|
||||||
|
drawAuthorLine = {
|
||||||
|
author?.let { (hex, name) ->
|
||||||
|
Text(name, color = authorNameColorFor(hex, isLight), fontWeight = ChatAuthorNameWeight, fontSize = ChatAuthorNameSize)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authorAvatar = if (author != null) ({ Face(faceColor) }) else null,
|
||||||
|
) { Text(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Composer(typed: String?) {
|
||||||
|
Surface(
|
||||||
|
shape = EditFieldBorder,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 14.dp, end = 4.dp)) {
|
||||||
|
Icon(MaterialSymbols.AddPhotoAlternate, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
typed ?: "Message",
|
||||||
|
color = if (typed != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ThinSendButton(isActive = typed != null, modifier = Modifier) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SampleGroupChat() {
|
||||||
|
Column {
|
||||||
|
Header()
|
||||||
|
ChatDivisor("Today")
|
||||||
|
ChatSystemCaption("You renamed the chat to “NosFabrica Product”")
|
||||||
|
Bubble("Renamed the chat to “NosFabrica Product”", mine = true, position = ChatGroupPosition.TOP)
|
||||||
|
Bubble("Ok, this is working way better than I expected", mine = true, position = ChatGroupPosition.BOTTOM, time = "8:38", fire = true)
|
||||||
|
Bubble("A cool goal of this is to delete messages from unknown users automatically", mine = true, time = "8:49")
|
||||||
|
Bubble(
|
||||||
|
"to prevent random users or bots out of network from sliding in DMs?",
|
||||||
|
mine = false,
|
||||||
|
time = "8:55",
|
||||||
|
author = OUTPOST to "Relay Outpost",
|
||||||
|
faceColor = Color(0xFF5A5A5A),
|
||||||
|
)
|
||||||
|
Bubble("Delete the past scammers", mine = true, time = "8:59", fire = true)
|
||||||
|
Bubble(
|
||||||
|
"Big fat red alert next to the accounts that need it.",
|
||||||
|
mine = false,
|
||||||
|
position = ChatGroupPosition.TOP,
|
||||||
|
author = DAVID to "david",
|
||||||
|
faceColor = Color(0xFFD9825B),
|
||||||
|
)
|
||||||
|
Bubble(
|
||||||
|
"Unlike every other nostr app that give you no useful warnings at all.",
|
||||||
|
mine = false,
|
||||||
|
position = ChatGroupPosition.BOTTOM,
|
||||||
|
time = "9:08",
|
||||||
|
author = DAVID to "david",
|
||||||
|
faceColor = Color(0xFFD9825B),
|
||||||
|
)
|
||||||
|
Bubble("But also, send a message to the relays that store DMs to delete those Giftwraps.", mine = true, time = "9:09")
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Composer(typed = null)
|
||||||
|
Composer(typed = "Sounds good")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user