fix: draw the "added you to a channel" prompt as a note row

The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.

Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.

The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.

Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
This commit is contained in:
Claude
2026-07-29 22:23:13 +00:00
parent 112fbb8379
commit 08b3cf266b
12 changed files with 99 additions and 51 deletions
@@ -139,7 +139,7 @@ fun MessagesPager(
// same state holder, so the two surfaces cannot disagree.
headerContent =
if (tabs[page].resource == R.string.new_requests) {
{ ChannelInvitesSection(accountViewModel) }
{ ChannelInvitesSection(accountViewModel, nav) }
} else {
null
},
@@ -21,12 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -35,15 +35,32 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -60,6 +77,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun ChannelInvitesSection(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val invites by accountViewModel.feedStates.channelInvites.flow
@@ -69,48 +87,94 @@ fun ChannelInvitesSection(
Column(modifier) {
invites.forEach { invite ->
ChannelInviteCard(invite, accountViewModel)
ChannelInviteCard(invite, accountViewModel, nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
/**
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
*/
@Composable
fun ChannelInviteCard(
invite: BuzzChannelInvite,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
val baseChannel =
remember(invite.channelId, invite.relay) {
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
}
// Metadata only — the same observer the channel rows use. It resolves the name a stranger's add
// arrives without, and never opens the channel's message subscription (which is the whole point of
// holding the invite back until the viewer answers).
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
) {
Column(Modifier.padding(12.dp)) {
Text(
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
Text(
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
// fading with a last-read marker: it is a standing question, not a dated event.
val backgroundColor =
MaterialTheme.colorScheme.newItemBackgroundColor
.compositeOver(MaterialTheme.colorScheme.background)
NoteComposeLayout(
modifier = Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth(),
authorPicture = {
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
if (actorUser != null) {
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
} else {
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
}
}
},
firstRow = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size5dp),
modifier = UserNameRowHeight,
) {
// Who did it matters: the relay reports a self-join with the same event, so naming the
// actor is what tells "I joined this" apart from "a stranger put me here".
text =
stringRes(
R.string.channel_invite_body,
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
invite.relay.displayUrl(),
),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
if (actorUser != null) {
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(R.string.channel_invite_unknown_actor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
TimeAgo(invite.createdAt)
}
},
secondRow = {},
noteContent = {
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
Text(
text = invite.relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
reactionsRow = {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
@@ -120,10 +184,12 @@ fun ChannelInviteCard(
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
Text(stringRes(R.string.channel_invite_ignore))
}
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
// "Add to Messages", so it carries that label rather than a second word for one action.
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
}
}
}
}
},
)
}
@@ -246,7 +246,7 @@ internal fun SingleNotificationsBody(
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
// standing decision, not a dated event, so they must not scroll away into history.
ChannelInvitesSection(accountViewModel)
ChannelInvitesSection(accountViewModel, nav)
},
)
}
@@ -2563,9 +2563,7 @@
<string name="relay_group_browse_popular">Oblíbené relaye</string>
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
<string name="channel_invite_title">Přidáno do %1$s</string>
<string name="channel_invite_body">%1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách?</string>
<string name="channel_invite_unknown_actor">Někdo</string>
<string name="channel_invite_accept">Zobrazit</string>
<string name="channel_invite_ignore">Ignorovat</string>
<string name="channel_invite_leave">Opustit</string>
<string name="relay_group_join_to_post">Připojte se k této skupině pro odesílání zpráv.</string>
@@ -2472,9 +2472,7 @@
<string name="relay_group_browse_popular">Beliebte Relays</string>
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
<string name="channel_invite_title">Zu %1$s hinzugefügt</string>
<string name="channel_invite_body">%1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen?</string>
<string name="channel_invite_unknown_actor">Jemand</string>
<string name="channel_invite_accept">Anzeigen</string>
<string name="channel_invite_ignore">Ignorieren</string>
<string name="channel_invite_leave">Verlassen</string>
<string name="relay_group_join_to_post">Tritt dieser Gruppe bei, um Nachrichten zu senden.</string>
@@ -2473,9 +2473,7 @@
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
<string name="channel_invite_title">%1$s में जोडा गया</string>
<string name="channel_invite_body">%1$s ने आपको इस प्रणाली में जोडा %2$s पर। क्या इसे सन्देशों में दिखाया जाए।</string>
<string name="channel_invite_unknown_actor">कोई व्यक्ति</string>
<string name="channel_invite_accept">दिखाएँ</string>
<string name="channel_invite_ignore">उपेक्षा करें</string>
<string name="channel_invite_leave">छोडें</string>
<string name="relay_group_join_to_post">इस समूह से जुडें सन्देशों को भेजने के लिए।</string>
@@ -2467,9 +2467,7 @@
<string name="relay_group_browse_popular">Populaire relays</string>
<string name="relay_group_no_messages_yet">Nog geen berichten</string>
<string name="channel_invite_title">Toegevoegd aan %1$s</string>
<string name="channel_invite_body">%1$s heeft je toegevoegd aan dit kanaal op %2$s. In Berichten tonen?</string>
<string name="channel_invite_unknown_actor">Iemand</string>
<string name="channel_invite_accept">Tonen</string>
<string name="channel_invite_ignore">Negeren</string>
<string name="channel_invite_leave">Verlaten</string>
<string name="relay_group_join_to_post">Neem deel aan deze groep om berichten te sturen.</string>
@@ -2565,9 +2565,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_group_browse_popular">Popularne transmitery</string>
<string name="relay_group_no_messages_yet">Brak wiadomości</string>
<string name="channel_invite_title">Dodano do %1$s</string>
<string name="channel_invite_body">%1$s dodał Cię do tego kanału na %2$s. Pokazać go w wiadomościach?</string>
<string name="channel_invite_unknown_actor">Ktoś</string>
<string name="channel_invite_accept">Pokaż</string>
<string name="channel_invite_ignore">Ignoruj</string>
<string name="channel_invite_leave">Wyjdź</string>
<string name="relay_group_join_to_post">Dołącz do tej grupy, aby wysyłać wiadomości.</string>
@@ -2473,9 +2473,7 @@
<string name="relay_group_browse_popular">Relays populares</string>
<string name="relay_group_no_messages_yet">Nenhuma mensagem ainda</string>
<string name="channel_invite_title">Adicionado a %1$s</string>
<string name="channel_invite_body">%1$s adicionou você a este canal em %2$s. Mostrar em Mensagens?</string>
<string name="channel_invite_unknown_actor">Alguém</string>
<string name="channel_invite_accept">Mostrar</string>
<string name="channel_invite_ignore">Ignorar</string>
<string name="channel_invite_leave">Sair</string>
<string name="relay_group_join_to_post">Entre neste grupo para enviar mensagens.</string>
@@ -2544,9 +2544,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_browse_popular">Popularni releji</string>
<string name="relay_group_no_messages_yet">Ni sporočil</string>
<string name="channel_invite_title">Dodano v: %1$s</string>
<string name="channel_invite_body">%1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili?</string>
<string name="channel_invite_unknown_actor">Nekdo</string>
<string name="channel_invite_accept">Prikaži</string>
<string name="channel_invite_ignore">Prezri</string>
<string name="channel_invite_leave">Zapusti</string>
<string name="relay_group_join_to_post">Pridružite se tej skupini, da boste lahko pošiljali sporočila.</string>
@@ -2475,9 +2475,7 @@
<string name="relay_group_browse_popular">Populära reläer</string>
<string name="relay_group_no_messages_yet">Inga meddelanden ännu</string>
<string name="channel_invite_title">Tillagd i %1$s</string>
<string name="channel_invite_body">%1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden?</string>
<string name="channel_invite_unknown_actor">Någon</string>
<string name="channel_invite_accept">Visa</string>
<string name="channel_invite_ignore">Ignorera</string>
<string name="channel_invite_leave">Lämna</string>
<string name="relay_group_join_to_post">Gå med i den här gruppen för att skicka meddelanden.</string>
-2
View File
@@ -2702,9 +2702,7 @@
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
<string name="channel_invite_title">Added to %1$s</string>
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
<string name="channel_invite_unknown_actor">Someone</string>
<string name="channel_invite_accept">Show</string>
<string name="channel_invite_ignore">Ignore</string>
<string name="channel_invite_leave">Leave</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>