mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Bring private and group chat headers up to the main header's layout
Both conversation headers were built on TopAppBar with a centred title, a back arrow on the left and everything else crowded into the title slot, at 14sp with 14dp icons. Moving between the timeline and a conversation visibly shifted the bar's height, insets and type. Introduces ConversationHeader, built from the main header's own tokens rather than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph in a 44dp slot so it lands exactly where the brand mark does, same -6dp optical nudge pulling the title toward it, same 17sp label. - Drops the back button; the close action on the right is the way out. Leaving a channel outright already lives on its row in the network sheet, so it does not need a second home beside the exit. - Leading glyph is the transport: globe over the internet, wifi/bluetooth/ routed on the mesh, matching the main header's channel button. - Actions are right-aligned and unweighted -- favourite, encryption state, close -- so a long title yields space to them instead of pushing them off screen. - Private chat titles use the primary green like every other header label, rather than orange for Nostr-reachable peers. Height and edge insets now belong to each header variant instead of the ChatFloatingHeader wrapper, which was applying them a second time to the channel header. Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth, wifi, routed, close, check, warning, sync, lock_open, envelope -- so the headers and peer rows no longer mix Material glyphs into the set.
This commit is contained in:
parent
5c8f03ce18
commit
e66ddde063
@ -42,6 +42,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
@ -80,6 +83,15 @@ private val HeaderTapTarget = 44.dp
|
||||
/** Corner radius for the header's tappable label+icon clusters. */
|
||||
private val HeaderClusterShape = RoundedCornerShape(8.dp)
|
||||
|
||||
/**
|
||||
* Edge insets for the bar.
|
||||
*
|
||||
* Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
|
||||
* some optical inset, while the trailing action's does the same on the other side.
|
||||
*/
|
||||
internal val HeaderInsetStart = 12.dp
|
||||
internal val HeaderInsetEnd = 8.dp
|
||||
|
||||
/**
|
||||
* A minimum-48x40 tap target wrapping a small icon.
|
||||
*
|
||||
@ -273,39 +285,151 @@ fun NoiseSessionIcon(
|
||||
// The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with
|
||||
// an all-but-transparent RGB - the icons were effectively invisible. They now use the
|
||||
// palette's secondary text colour.
|
||||
val (icon, color, contentDescription) = when (sessionState) {
|
||||
val (iconRes, color, contentDescription) = when (sessionState) {
|
||||
"uninitialized" -> Triple(
|
||||
Icons.Outlined.NoEncryption,
|
||||
R.drawable.ic_spec_lock_open,
|
||||
colorScheme.onSurfaceVariant,
|
||||
stringResource(R.string.cd_ready_for_handshake)
|
||||
)
|
||||
"handshaking" -> Triple(
|
||||
Icons.Outlined.Sync,
|
||||
R.drawable.ic_spec_sync,
|
||||
colorScheme.onSurfaceVariant,
|
||||
stringResource(R.string.cd_handshake_in_progress)
|
||||
)
|
||||
"established" -> Triple(
|
||||
Icons.Filled.Lock,
|
||||
palette.accentOrange,
|
||||
R.drawable.ic_spec_lock,
|
||||
colorScheme.primary,
|
||||
stringResource(R.string.cd_encrypted)
|
||||
)
|
||||
else -> { // "failed" or any other state
|
||||
Triple(
|
||||
Icons.Outlined.Warning,
|
||||
R.drawable.ic_spec_warning,
|
||||
colorScheme.error,
|
||||
stringResource(R.string.cd_handshake_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = color
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability glyph for a conversation, drawn from the same spec set the main header uses.
|
||||
*
|
||||
* Mirrors the main header's channel button: a globe for anything reached over the internet, the
|
||||
* range mark for the local mesh, and the more specific transport glyph when we know it.
|
||||
*/
|
||||
@DrawableRes
|
||||
internal fun conversationTransportIcon(
|
||||
isReachedOverInternet: Boolean,
|
||||
isWifiAware: Boolean,
|
||||
isDirect: Boolean
|
||||
): Int = when {
|
||||
isReachedOverInternet -> R.drawable.ic_spec_globe
|
||||
isWifiAware -> R.drawable.ic_spec_wifi
|
||||
isDirect -> R.drawable.ic_spec_bluetooth
|
||||
else -> R.drawable.ic_spec_routed
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared chrome for a conversation header — private chats and channels alike.
|
||||
*
|
||||
* Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
|
||||
* height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
|
||||
* lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
|
||||
* that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
|
||||
* move between the main timeline and a conversation.
|
||||
*
|
||||
* Actions are right-aligned and unweighted, so a long title yields space to them rather than
|
||||
* pushing them off screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ConversationHeader(
|
||||
@DrawableRes leadingIconRes: Int,
|
||||
leadingIconTint: Color,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
leadingContentDescription: String? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(HeaderTapTarget),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(leadingIconRes),
|
||||
contentDescription = leadingContentDescription,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = leadingIconTint
|
||||
)
|
||||
}
|
||||
|
||||
// Same optical correction as the main header: the 44.dp tap target leaves more gap
|
||||
// than the design wants between glyph and label.
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.offset(x = (-6).dp)
|
||||
.then(
|
||||
if (onTitleClick != null) {
|
||||
Modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(onClick = onTitleClick)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
|
||||
@Composable
|
||||
fun ConversationHeaderAction(
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) = HeaderIconButton(
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
content = content
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NicknameEditor(
|
||||
value: String,
|
||||
@ -486,51 +610,27 @@ private fun ChannelHeader(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that
|
||||
// the old "< back" label consumed.
|
||||
HeaderIconButton(
|
||||
// No back affordance: the close action on the right is the way out, exactly as in a private
|
||||
// chat. Leaving the channel outright lives on its row in the network sheet, so it does not
|
||||
// need a second, easily-mistaken home next to the exit.
|
||||
ConversationHeader(
|
||||
leadingIconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
leadingIconTint = colorScheme.primary,
|
||||
leadingContentDescription = null,
|
||||
title = "#$channel",
|
||||
onTitleClick = onSidebarClick
|
||||
) {
|
||||
ConversationHeaderAction(
|
||||
onClick = onBackClick,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = stringResource(R.string.close_plain),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.primary
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Title - perfectly centered regardless of other elements
|
||||
Text(
|
||||
text = "#$channel",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(onClick = onSidebarClick)
|
||||
.heightIn(min = HeaderTapTarget)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
.padding(horizontal = 10.dp)
|
||||
)
|
||||
|
||||
// Leave button - positioned on the right
|
||||
Text(
|
||||
text = stringResource(R.string.chat_leave),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontSize = 15.sp,
|
||||
color = colorScheme.error,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(onClick = onLeaveChannel)
|
||||
.heightIn(min = HeaderTapTarget)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
.padding(horizontal = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -556,7 +656,10 @@ private fun MainHeader(
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// MARK: - Identity cluster.
|
||||
@ -615,7 +718,7 @@ private fun MainHeader(
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = palette.accentOrange
|
||||
|
||||
@ -406,7 +406,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
ChatFloatingHeader(
|
||||
headerHeight = headerHeight,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
@ -674,6 +673,7 @@ fun ChatInputSection(
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@ -697,7 +697,6 @@ private const val BarBackgroundAlpha = 0.88f
|
||||
private const val HeaderOpaqueStop = 0.72f
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
headerHeight: Dp,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
@ -729,38 +728,31 @@ private fun ChatFloatingHeader(
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
|
||||
) {
|
||||
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
|
||||
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
|
||||
// header's spacing impossible to specify exactly.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(headerHeight)
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
// No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and
|
||||
// applies its own minimum heights, which made the header's spacing impossible to specify
|
||||
// exactly. Height and edge insets belong to each header variant, so that a conversation
|
||||
// header rendered here and one rendered in a sheet are laid out identically.
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,15 @@ internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
|
||||
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
|
||||
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
/**
|
||||
* Mention token grammar shared by rendered messages and the composer.
|
||||
*
|
||||
* The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
|
||||
* same nickname. Keeping one regex prevents the composer from styling only `@name` while the
|
||||
* rendered transcript styles the complete token.
|
||||
*/
|
||||
internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
|
||||
|
||||
/**
|
||||
* Get RSSI-based color for signal strength visualization
|
||||
*/
|
||||
@ -376,6 +385,23 @@ internal fun resolveMentionPeerIdentity(
|
||||
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deterministic color for a mention on every surface that displays one.
|
||||
*
|
||||
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
|
||||
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
|
||||
*/
|
||||
internal fun colorForMention(
|
||||
mention: String,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
palette: BitchatPalette,
|
||||
): Color {
|
||||
val mentionWithoutAt = mention.trim().removePrefix("@")
|
||||
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
|
||||
?: PeerIdentity.nickname(mentionWithoutAt)
|
||||
return colorForPeer(identity, palette)
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
|
||||
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
|
||||
@ -408,12 +434,10 @@ private fun appendIOSFormattedContent(
|
||||
linkColor: Color,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
) {
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
|
||||
|
||||
// Combine and sort matches, but exclude hashtags that overlap with mentions
|
||||
val mentionRanges = mentionMatches.map { it.range }
|
||||
@ -513,14 +537,10 @@ private fun appendIOSFormattedContent(
|
||||
val mentionColor = if (isMentionToMe) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
val identity = resolveMentionPeerIdentity(
|
||||
mentionWithoutAt,
|
||||
mentionPeerIdentities,
|
||||
)
|
||||
?: PeerIdentity.nickname(mentionWithoutAt)
|
||||
colorForPeer(
|
||||
identity,
|
||||
palette
|
||||
colorForMention(
|
||||
mention = mentionWithoutAt,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
|
||||
|
||||
@ -50,7 +50,6 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@ -65,11 +64,10 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatPalette
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import com.bitchat.android.features.voice.normalizeAmplitudeSample
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
|
||||
@ -91,38 +89,22 @@ class SlashCommandVisualTransformation(
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
|
||||
val annotatedString = buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
|
||||
slashCommandRegex.findAll(text.text).forEach { match ->
|
||||
// Add text before the match
|
||||
if (match.range.first > lastIndex) {
|
||||
append(text.text.substring(lastIndex, match.range.first))
|
||||
}
|
||||
|
||||
// Add the styled slash command
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = commandColor,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
background = commandBackground
|
||||
)
|
||||
) {
|
||||
append(match.value)
|
||||
}
|
||||
|
||||
lastIndex = match.range.last + 1
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.text.length) {
|
||||
append(text.text.substring(lastIndex))
|
||||
}
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
slashCommandRegex.findAll(text.text).forEach { match ->
|
||||
builder.addStyle(
|
||||
style = SpanStyle(
|
||||
color = commandColor,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
background = commandBackground
|
||||
),
|
||||
start = match.range.first,
|
||||
end = match.range.last + 1,
|
||||
)
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = annotatedString,
|
||||
text = builder.toAnnotatedString(),
|
||||
offsetMapping = OffsetMapping.Identity
|
||||
)
|
||||
}
|
||||
@ -133,45 +115,55 @@ class SlashCommandVisualTransformation(
|
||||
* while preserving cursor positioning and click handling
|
||||
*/
|
||||
class MentionVisualTransformation(
|
||||
private val mentionColor: Color,
|
||||
private val mentionBackground: Color,
|
||||
private val mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
private val palette: BitchatPalette,
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
|
||||
val annotatedString = buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
|
||||
mentionRegex.findAll(text.text).forEach { match ->
|
||||
// Add text before the match
|
||||
if (match.range.first > lastIndex) {
|
||||
append(text.text.substring(lastIndex, match.range.first))
|
||||
}
|
||||
|
||||
// Add the styled mention
|
||||
withStyle(
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
MENTION_TOKEN_REGEX.findAll(text.text).forEach { match ->
|
||||
val start = match.range.first
|
||||
val end = match.range.last + 1
|
||||
val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 }
|
||||
val suffixStart = suffixOffset?.let(start::plus) ?: end
|
||||
val mentionColor = colorForMention(
|
||||
mention = match.value,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
)
|
||||
|
||||
// Keep the whole token on one continuous color-derived chip.
|
||||
builder.addStyle(
|
||||
style = SpanStyle(
|
||||
background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA),
|
||||
),
|
||||
start = start,
|
||||
end = end,
|
||||
)
|
||||
builder.addStyle(
|
||||
style = SpanStyle(
|
||||
color = mentionColor,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
start = start,
|
||||
end = suffixStart,
|
||||
)
|
||||
if (suffixStart < end) {
|
||||
builder.addStyle(
|
||||
style = SpanStyle(
|
||||
color = mentionColor,
|
||||
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// Mirrors the mention chip used in rendered messages, so what you type
|
||||
// looks like what everyone will see.
|
||||
background = mentionBackground
|
||||
)
|
||||
) {
|
||||
append(match.value)
|
||||
}
|
||||
|
||||
lastIndex = match.range.last + 1
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.text.length) {
|
||||
append(text.text.substring(lastIndex))
|
||||
),
|
||||
start = suffixStart,
|
||||
end = end,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = annotatedString,
|
||||
text = builder.toAnnotatedString(),
|
||||
offsetMapping = OffsetMapping.Identity
|
||||
)
|
||||
}
|
||||
@ -313,6 +305,7 @@ fun MessageInput(
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
showMediaButtons: Boolean,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
@ -386,7 +379,11 @@ fun MessageInput(
|
||||
}),
|
||||
// Cap the growth so a pasted wall of text cannot swallow the message list.
|
||||
maxLines = 6,
|
||||
visualTransformation = remember(palette) {
|
||||
visualTransformation = remember(
|
||||
palette,
|
||||
colorScheme.primary,
|
||||
mentionPeerIdentities,
|
||||
) {
|
||||
CombinedVisualTransformation(
|
||||
listOf(
|
||||
SlashCommandVisualTransformation(
|
||||
@ -394,8 +391,8 @@ fun MessageInput(
|
||||
commandBackground = colorScheme.primary.copy(alpha = 0.14f),
|
||||
),
|
||||
MentionVisualTransformation(
|
||||
mentionColor = palette.accentOrange,
|
||||
mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA),
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -715,6 +712,7 @@ fun MentionSuggestionsBox(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
@ -734,11 +732,13 @@ fun MentionSuggestionsBox(
|
||||
items = suggestions,
|
||||
key = { suggestion -> suggestion.lowercase() }
|
||||
) { suggestion ->
|
||||
val identity = resolveMentionPeerIdentity(suggestion, mentionPeerIdentities)
|
||||
?: PeerIdentity.nickname(suggestion)
|
||||
MentionSuggestionItem(
|
||||
suggestion = suggestion,
|
||||
identity = identity,
|
||||
userColor = colorForMention(
|
||||
mention = suggestion,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
),
|
||||
onClick = { onSuggestionClick(suggestion) },
|
||||
modifier = Modifier.animateItem(
|
||||
fadeInSpec = tween(
|
||||
@ -759,12 +759,11 @@ fun MentionSuggestionsBox(
|
||||
@Composable
|
||||
fun MentionSuggestionItem(
|
||||
suggestion: String,
|
||||
identity: PeerIdentity,
|
||||
userColor: Color,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val userColor = colorForPeer(identity, palette)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val pressedBackground by animateColorAsState(
|
||||
|
||||
@ -38,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
@ -614,7 +615,7 @@ private fun PeerItem(
|
||||
)
|
||||
} else if (hasUnreadDM) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentOrange
|
||||
@ -635,11 +636,13 @@ private fun PeerItem(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
isWifiAware -> Icons.Filled.Wifi
|
||||
isDirect -> Icons.Outlined.Bluetooth
|
||||
else -> Icons.Filled.Route
|
||||
},
|
||||
painter = painterResource(
|
||||
conversationTransportIcon(
|
||||
isReachedOverInternet = false,
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
)
|
||||
),
|
||||
contentDescription = when {
|
||||
isWifiAware -> "Direct Wi-Fi Aware"
|
||||
isDirect -> "Direct Bluetooth"
|
||||
@ -680,7 +683,7 @@ private fun PeerItem(
|
||||
|
||||
if (isVerified) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
painter = painterResource(R.drawable.ic_spec_check),
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
@ -861,12 +864,7 @@ fun PrivateChatSheet(
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
@ -880,7 +878,7 @@ fun PrivateChatSheet(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
Spacer(modifier = Modifier.height(ChatHeaderHeight))
|
||||
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
|
||||
@ -948,118 +946,99 @@ fun PrivateChatSheet(
|
||||
)
|
||||
}
|
||||
|
||||
// TopBar (fixed at top, iOS-style)
|
||||
BitchatSheetCenterTopBar(
|
||||
onClose = onDismiss,
|
||||
// Header. Built from the same tokens as the main chat header rather than a
|
||||
// TopAppBar, so moving between the timeline and a conversation does not shift the
|
||||
// bar's height, insets or type.
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 16.dp)
|
||||
.size(32.dp)
|
||||
color = colorScheme.background
|
||||
) {
|
||||
ConversationHeader(
|
||||
leadingIconRes = conversationTransportIcon(
|
||||
isReachedOverInternet = isNostrPeer || isNostrReachableFavorite,
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
),
|
||||
// Reachability is a status, not an alert: it takes the muted chrome tint,
|
||||
// leaving the primary green for the name itself.
|
||||
leadingIconTint = colorScheme.onSurfaceVariant,
|
||||
leadingContentDescription = when {
|
||||
isNostrPeer || isNostrReachableFavorite ->
|
||||
stringResource(R.string.cd_nostr_reachable)
|
||||
else -> null
|
||||
},
|
||||
title = titleText
|
||||
) {
|
||||
ConversationHeaderAction(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
contentDescription = if (isFavorite) {
|
||||
stringResource(R.string.cd_remove_favorite)
|
||||
} else {
|
||||
stringResource(R.string.cd_add_favorite)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.chat_back),
|
||||
tint = colorScheme.onSurface
|
||||
painter = painterResource(
|
||||
if (isFavorite) {
|
||||
R.drawable.ic_spec_star_filled
|
||||
} else {
|
||||
R.drawable.ic_spec_star
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = if (isFavorite) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
// Center content: connection status + name + encryption
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isNostrPeer || isNostrReachableFavorite -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_globe),
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9C27B0)
|
||||
)
|
||||
}
|
||||
isWifiAware -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wifi,
|
||||
contentDescription = "Direct Wi-Fi Aware",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isDirect -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bluetooth,
|
||||
contentDescription = "Direct Bluetooth",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isConnected -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Route,
|
||||
contentDescription = "Routed",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.then(securityModifier)
|
||||
// Encryption state, and the verification badge that qualifies it. Both are
|
||||
// read-only for Nostr peers, which have no Noise session at all.
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
ConversationHeaderAction(
|
||||
onClick = { viewModel.showSecurityVerificationSheet() },
|
||||
contentDescription = stringResource(R.string.verify_title)
|
||||
) {
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B) // iOS Green
|
||||
modifier = Modifier.size(HeaderIconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
if (isVerified) {
|
||||
Box(
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isFavorite) {
|
||||
R.drawable.ic_spec_star_filled
|
||||
} else {
|
||||
R.drawable.ic_spec_star
|
||||
}
|
||||
),
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
painter = painterResource(R.drawable.ic_spec_check),
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
ConversationHeaderAction(
|
||||
onClick = { dismiss?.invoke() ?: onDismiss() },
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
app/src/main/res/drawable/ic_spec_bluetooth.xml
Normal file
9
app/src/main/res/drawable/ic_spec_bluetooth.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,2.75V17.25"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,2.75L14.5,7.25L5.5,12.75"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,17.25L14.5,12.75L5.5,7.25"/>
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_spec_check.xml
Normal file
7
app/src/main/res/drawable/ic_spec_check.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M4.25,10.5L8.25,14.5L15.75,6"/>
|
||||
</vector>
|
||||
8
app/src/main/res/drawable/ic_spec_close.xml
Normal file
8
app/src/main/res/drawable/ic_spec_close.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M5.5,5.5L14.5,14.5"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M14.5,5.5L5.5,14.5"/>
|
||||
</vector>
|
||||
8
app/src/main/res/drawable/ic_spec_envelope.xml
Normal file
8
app/src/main/res/drawable/ic_spec_envelope.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M15.75,15.5H4.25C3.2145,15.5 2.375,14.6605 2.375,13.625V6.375C2.375,5.3395 3.2145,4.5 4.25,4.5H15.75C16.7855,4.5 17.625,5.3395 17.625,6.375V13.625C17.625,14.6605 16.7855,15.5 15.75,15.5Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.1,5.75L10,10.85L16.9,5.75"/>
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_spec_lock_open.xml
Normal file
13
app/src/main/res/drawable/ic_spec_lock_open.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<!-- Body and shackle geometry copied from ic_spec_lock so the two states sit on exactly the
|
||||
same baseline; only the shackle is swung open, hinged on its right leg. -->
|
||||
<group android:translateX="-16" android:translateY="-310">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M32.25,329.375H19.75C18.715,329.375 17.875,328.535 17.875,327.5V321.25C17.875,320.215 18.715,319.375 19.75,319.375H32.25C33.285,319.375 34.125,320.215 34.125,321.25V327.5C34.125,328.535 33.285,329.375 32.25,329.375Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M34.125,316.875V315C34.125,312.584 32.1662,310.625 29.75,310.625C27.3338,310.625 25.375,312.584 25.375,315V316.5"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M26,326.25C27.0355,326.25 27.875,325.411 27.875,324.375C27.875,323.339 27.0355,322.5 26,322.5C24.9645,322.5 24.125,323.339 24.125,324.375C24.125,325.411 24.9645,326.25 26,326.25Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_spec_routed.xml
Normal file
11
app/src/main/res/drawable/ic_spec_routed.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M6,5.25A1.75,1.75 0 1,1 2.5,5.25A1.75,1.75 0 1,1 6,5.25Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M17.5,5.25A1.75,1.75 0 1,1 14,5.25A1.75,1.75 0 1,1 17.5,5.25Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M11.75,15.75A1.75,1.75 0 1,1 8.25,15.75A1.75,1.75 0 1,1 11.75,15.75Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M5.1,6.9L9.3,14.1"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M14.9,6.9L10.7,14.1"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_spec_sync.xml
Normal file
10
app/src/main/res/drawable/ic_spec_sync.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.75,10A6.25,6.25 0 0,1 14.9,6.15"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M11.4,6.6L15.1,6.4L14.9,2.7"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M16.25,10A6.25,6.25 0 0,1 5.1,13.85"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M8.6,13.4L4.9,13.6L5.1,17.3"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_spec_warning.xml
Normal file
9
app/src/main/res/drawable/ic_spec_warning.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,3.25L17.75,16.75H2.25L10,3.25Z"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,8V11.5"/>
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M10.9,14.25A0.9,0.9 0 1,1 9.1,14.25A0.9,0.9 0 1,1 10.9,14.25Z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_spec_wifi.xml
Normal file
9
app/src/main/res/drawable/ic_spec_wifi.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.5,8.5C6.1,5.7 13.9,5.7 16.5,8.5"/>
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M6.4,11.6C8.1,9.8 11.9,9.8 13.6,11.6"/>
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M11,15.25A1,1 0 1,1 9,15.25A1,1 0 1,1 11,15.25Z"/>
|
||||
</vector>
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">إضافة إشارة مرجعية</string>
|
||||
|
||||
<!-- ترويسة الدردشة وسهولة الوصول -->
|
||||
<string name="chat_back">رجوع</string>
|
||||
<string name="chat_leave">مغادرة</string>
|
||||
<string name="cd_nostr_reachable">يمكن الوصول عبر Nostr</string>
|
||||
<string name="cd_unread_private_messages">رسائل خاصة غير مقروءة</string>
|
||||
<string name="cd_teleported">تم الانتقال</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">বুকমার্ক যোগ করুন</string>
|
||||
|
||||
<!-- চ্যাট হেডার ও অ্যাক্সেসিবিলিটি -->
|
||||
<string name="chat_back">ফিরে যান</string>
|
||||
<string name="chat_leave">ছেড়ে দিন</string>
|
||||
<string name="cd_nostr_reachable">Nostr এর মাধ্যমে পৌঁছানো যায়</string>
|
||||
<string name="cd_unread_private_messages">অপঠিত ব্যক্তিগত বার্তা</string>
|
||||
<string name="cd_teleported">টেলিপোর্ট করা হয়েছে</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Lesezeichen hinzufügen</string>
|
||||
|
||||
<!-- Chat‑Kopf & Barrierefreiheit -->
|
||||
<string name="chat_back">zurück</string>
|
||||
<string name="chat_leave">verlassen</string>
|
||||
<string name="cd_nostr_reachable">Über Nostr erreichbar</string>
|
||||
<string name="cd_unread_private_messages">Ungelesene private Nachrichten</string>
|
||||
<string name="cd_teleported">Teleportiert</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Agregar marcador</string>
|
||||
|
||||
<!-- Encabezado de chat y accesibilidad -->
|
||||
<string name="chat_back">atrás</string>
|
||||
<string name="chat_leave">salir</string>
|
||||
<string name="cd_nostr_reachable">Accesible vía Nostr</string>
|
||||
<string name="cd_unread_private_messages">Mensajes privados sin leer</string>
|
||||
<string name="cd_teleported">Teletransportado</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">افزودن نشانک</string>
|
||||
|
||||
<!-- سربرگ چت و دسترسیپذیری -->
|
||||
<string name="chat_back">بازگشت</string>
|
||||
<string name="chat_leave">خروج</string>
|
||||
<string name="cd_nostr_reachable">قابل دسترس از طریق Nostr</string>
|
||||
<string name="cd_unread_private_messages">پیامهای خصوصی خواندهنشده</string>
|
||||
<string name="cd_teleported">انتقال مکانی</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Mag‑bookmark</string>
|
||||
|
||||
<!-- Header at accessibility -->
|
||||
<string name="chat_back">bumalik</string>
|
||||
<string name="chat_leave">umalis</string>
|
||||
<string name="cd_nostr_reachable">Maabot sa Nostr</string>
|
||||
<string name="cd_unread_private_messages">Hindi nabasang pribadong mensahe</string>
|
||||
<string name="cd_teleported">Naiteleport</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Ajouter un signet</string>
|
||||
|
||||
<!-- En‑tête de chat & accessibilité -->
|
||||
<string name="chat_back">retour</string>
|
||||
<string name="chat_leave">quitter</string>
|
||||
<string name="cd_nostr_reachable">Joignable via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Messages privés non lus</string>
|
||||
<string name="cd_teleported">Téléporté</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">बुकमार्क जोड़ें</string>
|
||||
|
||||
<!-- चैट हेडर & एक्सेसिबिलिटी -->
|
||||
<string name="chat_back">वापस</string>
|
||||
<string name="chat_leave">छोड़ें</string>
|
||||
<string name="cd_nostr_reachable">Nostr के माध्यम से उपलब्ध</string>
|
||||
<string name="cd_unread_private_messages">अपठित निजी संदेश</string>
|
||||
<string name="cd_teleported">टेलीपोर्ट हुआ</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Tambah bookmark</string>
|
||||
|
||||
<!-- Header chat & aksesibilitas -->
|
||||
<string name="chat_back">kembali</string>
|
||||
<string name="chat_leave">keluar</string>
|
||||
<string name="cd_nostr_reachable">Dapat dijangkau melalui Nostr</string>
|
||||
<string name="cd_unread_private_messages">Pesan pribadi yang belum dibaca</string>
|
||||
<string name="cd_teleported">Diteleportasi</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Aggiungi segnalibro</string>
|
||||
|
||||
<!-- Intestazione chat e accessibilità -->
|
||||
<string name="chat_back">indietro</string>
|
||||
<string name="chat_leave">esci</string>
|
||||
<string name="cd_nostr_reachable">Raggiungibile via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Messaggi privati non letti</string>
|
||||
<string name="cd_teleported">Teletrasportato</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">ブックマークに追加</string>
|
||||
|
||||
<!-- チャットヘッダー & アクセシビリティ -->
|
||||
<string name="chat_back">戻る</string>
|
||||
<string name="chat_leave">退出</string>
|
||||
<string name="cd_nostr_reachable">Nostr 経由で到達可能</string>
|
||||
<string name="cd_unread_private_messages">未読のプライベートメッセージ</string>
|
||||
<string name="cd_teleported">テレポート済み</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">სანიშნეს დამატება</string>
|
||||
|
||||
<!-- ჩათის სათაური & ხელმისაწვდომობა -->
|
||||
<string name="chat_back">უკან</string>
|
||||
<string name="chat_leave">გასვლა</string>
|
||||
<string name="cd_nostr_reachable">მისაწვდომია Nostr-ით</string>
|
||||
<string name="cd_unread_private_messages">წაუკითხავი პირადი შეტყობინებები</string>
|
||||
<string name="cd_teleported">ტელეპორტირებული</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">북마크 추가</string>
|
||||
|
||||
<!-- 채팅 헤더 & 접근성 -->
|
||||
<string name="chat_back">뒤로</string>
|
||||
<string name="chat_leave">나가기</string>
|
||||
<string name="cd_nostr_reachable">Nostr로 연결 가능</string>
|
||||
<string name="cd_unread_private_messages">읽지 않은 개인 메시지</string>
|
||||
<string name="cd_teleported">텔레포트됨</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Hanampy bookmark</string>
|
||||
|
||||
<!-- Lohan\'ny resaka & fahafaha-miditra -->
|
||||
<string name="chat_back">miverina</string>
|
||||
<string name="chat_leave">hiala</string>
|
||||
<string name="cd_nostr_reachable">Azo tratrarina amin\'ny alalan\'ny Nostr</string>
|
||||
<string name="cd_unread_private_messages">Hafatra manokana tsy voavaky</string>
|
||||
<string name="cd_teleported">Nafindra toerana</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">बुकमार्क थप्नुहोस्</string>
|
||||
|
||||
<!-- च्याट शीर्षक / पहुँचयोग्यता -->
|
||||
<string name="chat_back">पछाडि</string>
|
||||
<string name="chat_leave">छोड्नुहोस्</string>
|
||||
<string name="cd_nostr_reachable">Nostr मार्फत पुग्न सकिने</string>
|
||||
<string name="cd_unread_private_messages">नपढिएका निजी सन्देश</string>
|
||||
<string name="cd_teleported">टेलिपोर्ट भयो</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Bladwijzer toevoegen</string>
|
||||
|
||||
<!-- Chatkop & toegankelijkheid -->
|
||||
<string name="chat_back">terug</string>
|
||||
<string name="chat_leave">verlaten</string>
|
||||
<string name="cd_nostr_reachable">Bereikbaar via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Ongelezen privéberichten</string>
|
||||
<string name="cd_teleported">Geteleporteerd</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">بُک مارک پاؤ</string>
|
||||
|
||||
<!-- چیٹ ہیڈر & رسائی پذیری -->
|
||||
<string name="chat_back">واپس</string>
|
||||
<string name="chat_leave">باہر آؤ</string>
|
||||
<string name="cd_nostr_reachable">Nostr راہین پہنچ ممکن اے</string>
|
||||
<string name="cd_unread_private_messages">انہ پڑھیا ذاتی پیغام</string>
|
||||
<string name="cd_teleported">ٹیلپورٹ کیتا گیا</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Adicionar favorito</string>
|
||||
|
||||
<!-- Cabeçalho do chat & acessibilidade -->
|
||||
<string name="chat_back">voltar</string>
|
||||
<string name="chat_leave">sair</string>
|
||||
<string name="cd_nostr_reachable">Acessível via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
|
||||
<string name="cd_teleported">Teletransportado</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Adicionar marcador</string>
|
||||
|
||||
<!-- Cabeçalho do chat e acessibilidade -->
|
||||
<string name="chat_back">voltar</string>
|
||||
<string name="chat_leave">sair</string>
|
||||
<string name="cd_nostr_reachable">Alcançável via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
|
||||
<string name="cd_teleported">Teletransportado</string>
|
||||
|
||||
@ -63,8 +63,6 @@
|
||||
<string name="cd_remove_favorite">Убрать из избранного</string>
|
||||
<string name="cd_add_bookmark">Добавить закладку</string>
|
||||
|
||||
<string name="chat_back">назад</string>
|
||||
<string name="chat_leave">выйти</string>
|
||||
<string name="cd_nostr_reachable">Доступен через Nostr</string>
|
||||
<string name="cd_unread_private_messages">Непрочитанные личные</string>
|
||||
<string name="cd_teleported">Телепортировано</string>
|
||||
|
||||
@ -63,8 +63,6 @@
|
||||
<string name="cd_remove_favorite">Ta bort favorit</string>
|
||||
<string name="cd_add_bookmark">Lägg till bokmärke</string>
|
||||
|
||||
<string name="chat_back">tillbaka</string>
|
||||
<string name="chat_leave">lämna</string>
|
||||
<string name="cd_nostr_reachable">Nås via Nostr</string>
|
||||
<string name="cd_unread_private_messages">Olästa privata meddelanden</string>
|
||||
<string name="cd_teleported">Teleporterad</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">เพิ่มที่คั่นหน้า</string>
|
||||
|
||||
<!-- ส่วนหัวแชท & การช่วยสำหรับการเข้าถึง -->
|
||||
<string name="chat_back">กลับ</string>
|
||||
<string name="chat_leave">ออก</string>
|
||||
<string name="cd_nostr_reachable">ติดต่อได้ผ่าน Nostr</string>
|
||||
<string name="cd_unread_private_messages">ข้อความส่วนตัวยังไม่ได้อ่าน</string>
|
||||
<string name="cd_teleported">เทเลพอร์ตแล้ว</string>
|
||||
|
||||
@ -63,8 +63,6 @@
|
||||
<string name="cd_remove_favorite">Sık kullanılandan kaldır</string>
|
||||
<string name="cd_add_bookmark">Yer imi ekle</string>
|
||||
|
||||
<string name="chat_back">geri</string>
|
||||
<string name="chat_leave">ayrıl</string>
|
||||
<string name="cd_nostr_reachable">Nostr üzerinden ulaşılabilir</string>
|
||||
<string name="cd_unread_private_messages">Okunmamış özel mesaj</string>
|
||||
<string name="cd_teleported">Teleport edildi</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">بک مارک شامل کریں</string>
|
||||
|
||||
<!-- چیٹ ہیڈر اور رسائی -->
|
||||
<string name="chat_back">واپس</string>
|
||||
<string name="chat_leave">چھوڑیں</string>
|
||||
<string name="cd_nostr_reachable">Nostr کے ذریعے رسائی</string>
|
||||
<string name="cd_unread_private_messages">غیر پڑھے ہوئے نجی پیغامات</string>
|
||||
<string name="cd_teleported">ٹیلی پورٹ</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">Thêm bookmark</string>
|
||||
|
||||
<!-- Header trò chuyện & khả năng truy cập -->
|
||||
<string name="chat_back">quay lại</string>
|
||||
<string name="chat_leave">rời khỏi</string>
|
||||
<string name="cd_nostr_reachable">Có thể tiếp cận qua Nostr</string>
|
||||
<string name="cd_unread_private_messages">Tin nhắn riêng chưa đọc</string>
|
||||
<string name="cd_teleported">Đã dịch chuyển</string>
|
||||
|
||||
@ -71,8 +71,6 @@
|
||||
<string name="cd_add_bookmark">添加书签</string>
|
||||
|
||||
<!-- 聊天头部 / 可访问性 -->
|
||||
<string name="chat_back">返回</string>
|
||||
<string name="chat_leave">离开</string>
|
||||
<string name="cd_nostr_reachable">可通过 Nostr 联系</string>
|
||||
<string name="cd_unread_private_messages">未读私信</string>
|
||||
<string name="cd_teleported">已传送</string>
|
||||
|
||||
@ -72,8 +72,6 @@
|
||||
<string name="cd_add_bookmark">Add bookmark</string>
|
||||
|
||||
<!-- Chat header & accessibility -->
|
||||
<string name="chat_back">Back</string>
|
||||
<string name="chat_leave">Leave</string>
|
||||
<string name="cd_nostr_reachable">Nostr reachable</string>
|
||||
<string name="cd_unread_private_messages">Unread private messages</string>
|
||||
<string name="cd_location_notes">Location notes</string>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
@ -252,6 +253,40 @@ class ChatUIUtilsTest {
|
||||
assertTrue(body.spanStyles.any { it.item.color == expected })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `composer colors nickname and hash suffix from the mentioned peer identity`() {
|
||||
val pubkey = "0123456789abcdef".repeat(4)
|
||||
val identity = PeerIdentity.nostr(pubkey)
|
||||
val token = "@carol#04af"
|
||||
val input = "ping $token now"
|
||||
val transformed = MentionVisualTransformation(
|
||||
mentionPeerIdentities = mapOf("carol#04af" to identity),
|
||||
palette = palette,
|
||||
).filter(AnnotatedString(input)).text
|
||||
|
||||
val expectedColor = colorForPeer(identity, palette)
|
||||
val tokenStart = input.indexOf(token)
|
||||
val suffixStart = input.indexOf("#04af")
|
||||
val tokenEnd = tokenStart + token.length
|
||||
|
||||
assertEquals(input, transformed.text)
|
||||
assertTrue(transformed.spanStyles.any {
|
||||
it.start == tokenStart &&
|
||||
it.end == tokenEnd &&
|
||||
it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA)
|
||||
})
|
||||
assertTrue(transformed.spanStyles.any {
|
||||
it.start == tokenStart &&
|
||||
it.end == suffixStart &&
|
||||
it.item.color == expectedColor
|
||||
})
|
||||
assertTrue(transformed.spanStyles.any {
|
||||
it.start == suffixStart &&
|
||||
it.end == tokenEnd &&
|
||||
it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA)
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous base nickname is not assigned to the wrong peer`() {
|
||||
val firstIdentity = PeerIdentity.nostr("11111111".repeat(8))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user