diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt
index b0c6a6db30..371939fece 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt
@@ -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),
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt
index 0c4d8707ba..2d87e85c63 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt
@@ -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) },
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt
index 419eaac887..a176698ffe 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt
@@ -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(
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 700854f53e..db1144d0e4 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1969,6 +1969,9 @@
Open group
No groups on this relay yet.
Preparing invite…
+ Private
+ Invite-only
+ Enter a valid relay URL (wss://…).
No threads yet. Start one with the + button.
New thread
Untitled