mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(nip29): richer group card + threads/browse polish
Card glow-up (from the UI review): ElevatedCard with a subtle avatar ring, the name promoted to titleMedium, a tonal "Private"/"Invite-only" status pill, and a member-count chip with a people icon + primary-tinted chevron — so the inline card reads as a living community, not a link row. Still fixed-layout / no reflow. Threads: gate the compose FAB on membership (a non-member's kind-11 is rejected by the relay), make per-thread reply counts reactive via observeNoteReplyCount (so a new kind-1111 comment bumps the count live), and use leading dividers. Browse: show an inline error when the pasted relay URL doesn't normalize, and drop a redundant Row wrapper around the text field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,8 +28,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -100,27 +104,28 @@ private fun RelayGroupCardContent(
|
||||
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
// Relay host is known immediately; the member count fills in when the roster loads.
|
||||
// Both render on the same single line so nothing reflows as the count arrives.
|
||||
val relayLabel = channel.groupId.relayUrl.displayUrl()
|
||||
val subtitle =
|
||||
if (memberCount > 0) {
|
||||
"$relayLabel · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}"
|
||||
} else {
|
||||
relayLabel
|
||||
|
||||
// Closed (invite-only) is the more actionable signal to a prospective joiner than
|
||||
// private, so it wins when both are set. Null for a plain open group.
|
||||
val statusBadge =
|
||||
when {
|
||||
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
|
||||
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Shown only when the group actually has an about, so description-less groups stay
|
||||
// compact. It appears (a small one-time grow) once the relay-signed metadata loads.
|
||||
val description = channel.summary()?.takeIf { it.isNotBlank() }
|
||||
|
||||
OutlinedCard(
|
||||
ElevatedCard(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode),
|
||||
)
|
||||
},
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
@@ -133,7 +138,11 @@ private fun RelayGroupCardContent(
|
||||
robot = channel.groupId.id,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = channel.toBestDisplayName(),
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
@@ -142,38 +151,58 @@ private fun RelayGroupCardContent(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (channel.isPrivate()) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (statusBadge != null) {
|
||||
StatusPill(statusBadge)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "·",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = relayLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = stringRes(R.string.relay_group_open),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,3 +219,19 @@ private fun RelayGroupCardContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small tonal pill flagging group visibility/join policy (e.g. Private / Invite-only). */
|
||||
@Composable
|
||||
private fun StatusPill(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-15
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -77,12 +76,17 @@ fun RelayGroupBrowseScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
var relayUrl by remember { mutableStateOf("") }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
fun open(url: String) {
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())
|
||||
if (normalized == null) {
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
nav.nav(Route.RelayGroupServer(normalized.url))
|
||||
}
|
||||
|
||||
@@ -114,20 +118,26 @@ fun RelayGroupBrowseScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
OutlinedTextField(
|
||||
value = relayUrl,
|
||||
onValueChange = {
|
||||
relayUrl = it
|
||||
showError = false
|
||||
},
|
||||
singleLine = true,
|
||||
isError = showError,
|
||||
supportingText =
|
||||
if (showError) {
|
||||
{ Text(stringRes(R.string.relay_group_browse_invalid_url)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
label = { Text(stringRes(R.string.relay_group_browse_relay_label)) },
|
||||
placeholder = { Text("wss://…") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { open(relayUrl) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = relayUrl,
|
||||
onValueChange = { relayUrl = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringRes(R.string.relay_group_browse_relay_label)) },
|
||||
placeholder = { Text("wss://…") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { open(relayUrl) }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { open(relayUrl) },
|
||||
|
||||
+21
-10
@@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
@@ -98,6 +99,10 @@ private fun RelayGroupThreads(
|
||||
val threads by channel.threads.collectAsStateWithLifecycle()
|
||||
var showCompose by remember { mutableStateOf(false) }
|
||||
|
||||
// Only members can post a thread (the relay rejects a non-member's kind-11), so the
|
||||
// compose FAB is hidden for everyone else.
|
||||
val canPost = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
@@ -122,12 +127,14 @@ private fun RelayGroupThreads(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showCompose = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.relay_group_thread_new),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
if (canPost) {
|
||||
FloatingActionButton(onClick = { showCompose = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.relay_group_thread_new),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
@@ -142,9 +149,11 @@ private fun RelayGroupThreads(
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding)) {
|
||||
items(threads, key = { it.idHex }) { thread ->
|
||||
itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +171,9 @@ private fun ThreadRow(
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// Observe the reply count so a kind-1111 comment arriving on an already-listed thread
|
||||
// bumps it live (channel.threads only re-emits on add/remove of a thread).
|
||||
val replyCount by observeNoteReplyCount(thread, accountViewModel)
|
||||
val event = thread.event as? ThreadEvent
|
||||
val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled)
|
||||
val preview =
|
||||
@@ -170,7 +182,6 @@ private fun ThreadRow(
|
||||
?.replace('\n', ' ')
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
val replyCount = thread.replies.size
|
||||
val author = thread.author
|
||||
|
||||
Row(
|
||||
|
||||
@@ -1969,6 +1969,9 @@
|
||||
<string name="relay_group_open">Open group</string>
|
||||
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
|
||||
<string name="relay_group_invite_preparing">Preparing invite…</string>
|
||||
<string name="relay_group_badge_private">Private</string>
|
||||
<string name="relay_group_badge_invite_only">Invite-only</string>
|
||||
<string name="relay_group_browse_invalid_url">Enter a valid relay URL (wss://…).</string>
|
||||
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
|
||||
<string name="relay_group_thread_new">New thread</string>
|
||||
<string name="relay_group_thread_untitled">Untitled</string>
|
||||
|
||||
Reference in New Issue
Block a user