mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
refactor: render channel invites through NoteCompose like every other row
The invite row was a hand-assembled NoteComposeLayout with all five slots filled in by hand. That predates this branch — promoting invites into the Card pipeline renamed it and wired it into RenderCardItem without ever looking inside it, so the plumbing became standard while the row itself stayed bespoke, and it was missing everything NoteCompose provides: reply, boost, like, zap, share, the 3-dot menu, and click-through to open what it is about. NoteCompose already owns all of that chrome and RenderNoteRow is a `when` on the event kind supplying only the body — the same extension point RenderBadgeAward and ~30 others use. So kind 44100 gets a branch there, RenderChannelInvite supplies the body, and the card and the Messages section both just call NoteCompose. Nothing about the chrome is re-implemented. The body carries what the row is actually about: the channel's picture, name, member count, host relay and description, tappable through to the channel so the viewer can look before deciding (Route.RelayGroup opens a group that is not on kind-10009 yet, which is exactly this case), and the Leave / Ignore / Add to Messages actions with Accept promoted to a filled Button. It also names the actor inline. A kind-44100 is signed by the relay keypair — the relay is reporting a membership change it made — so NoteCompose's author header shows the relay, which is correct but does not say who added you. That moves into the body with their avatar and name; the hand-built row had cheated by putting the actor in the author slot. Note this now offers boost/zap/share on a relay-signed, `#p`-gated event: boost republishes a membership notification to your followers, zap pays the relay keypair rather than the actor, and share yields an nevent nobody else can fetch. Raised before implementing; kept deliberately for consistency with every other notification row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
This commit is contained in:
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelInvite
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||
@@ -231,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
@@ -1080,6 +1082,10 @@ private fun RenderNoteRow(
|
||||
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is MemberAddedNotificationEvent -> {
|
||||
RenderChannelInvite(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The body of a "somebody added you to a channel" row (kind 44100).
|
||||
*
|
||||
* Only the body: the row itself is an ordinary [com.vitorpamplona.amethyst.ui.note.NoteCompose], so the
|
||||
* author header, 3-dot menu, reactions row, last-read background and click-through all come from the
|
||||
* same code every other notification uses. This used to be a hand-assembled `NoteComposeLayout` with
|
||||
* every slot filled in by hand, which is why it had none of that.
|
||||
*
|
||||
* ### The author header names the relay, so the body names the actor
|
||||
*
|
||||
* A kind-44100 is signed by the **relay keypair** — the relay is reporting a membership change it made,
|
||||
* so it really is the author. `NoteCompose` draws its header from `note.author` and will therefore show
|
||||
* the relay. Who actually added you lives in the event's body, so it is rendered here, inline, with
|
||||
* their avatar and name: "Alice added you". That distinction matters — the relay emits the identical
|
||||
* kind for a self-join, and the actor is the only thing telling "I joined this" from "a stranger put me
|
||||
* here" (the invite projection filters self-joins out before a card is ever built, so anything reaching
|
||||
* this renderer was somebody else's doing).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderChannelInvite(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val notice = remember(note) { note.toMembershipNotice() } ?: return
|
||||
if (notice.removed) return
|
||||
|
||||
val groupId = remember(notice) { GroupId(notice.channelId, notice.relay) }
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
|
||||
// Recompose in place when the relay-signed metadata / roster changes, so the name, picture and
|
||||
// member count fill in and stay current without the row being rebuilt.
|
||||
val channelState by
|
||||
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(notice.actor) { notice.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Who did this, since the header above is the relay rather than a person.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (actorUser != null) {
|
||||
UserPicture(actorUser, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
UsernameDisplay(actorUser, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_unknown_actor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_added_you),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// The channel itself, tappable so the viewer can look before deciding. Route.RelayGroup opens a
|
||||
// group that is not on my kind-10009 yet, which is exactly this case.
|
||||
RelayGroupSummary(
|
||||
channel = channel,
|
||||
autoPlayGif = autoPlayGif,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
// 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.
|
||||
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(notice.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.
|
||||
Button(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.add_to_messages))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact channel identity — picture, name, member count and host relay — shared by the invite body. */
|
||||
@Composable
|
||||
private fun RelayGroupSummary(
|
||||
channel: RelayGroupChannel,
|
||||
autoPlayGif: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val memberCount = channel.memberCount()
|
||||
val description = channel.summary()?.takeIf { it.isNotBlank() }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channel.groupId.id,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = channel.toBestDisplayName(),
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (memberCount > 0) {
|
||||
"$memberCount · " + channel.groupId.relayUrl.displayUrl()
|
||||
} else {
|
||||
channel.groupId.relayUrl.displayUrl()
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -400,8 +400,17 @@ private fun RenderCardItem(
|
||||
}
|
||||
|
||||
is ChannelInviteCard -> {
|
||||
ChannelInviteCompose(
|
||||
item.invite,
|
||||
// The same NoteCompose every other row uses. The invite-specific part is only the body,
|
||||
// dispatched by kind in RenderNoteRow — so the author header, 3-dot menu, reactions row,
|
||||
// last-read background and click-through are the shared ones rather than re-implemented.
|
||||
NoteCompose(
|
||||
baseNote = item.note,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = false,
|
||||
isQuotedNote = false,
|
||||
isHiddenFeed = showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
+23
-153
@@ -20,59 +20,32 @@
|
||||
*/
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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.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.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.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
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
|
||||
|
||||
/**
|
||||
* "Somebody added you to a channel" prompts, rendered as a block inside Messages › New Requests.
|
||||
*
|
||||
* On the Notifications tab these are not a section at all: each pending invite is a
|
||||
* [ChannelInviteCard] in the ordinary card feed, built by `convertToCard` and drawn by the same
|
||||
* `RenderCardItem` dispatch as every other row — see
|
||||
* [com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard] for why they sort to the top. This
|
||||
* section exists because Messages has its own list that is not a `Card` feed.
|
||||
* [ChannelInviteCard] in the ordinary card feed. Here Messages has its own list, which is not a `Card`
|
||||
* feed, so the same rows are drawn directly.
|
||||
*
|
||||
* Either way the row itself is a plain [NoteCompose] over the relay's kind-44100 — same author header,
|
||||
* 3-dot menu, reactions row and click-through as every other note — with only the body supplied per
|
||||
* kind by `RenderNoteRow` → `RenderChannelInvite`. This used to be a hand-assembled `NoteComposeLayout`
|
||||
* that reimplemented a fraction of that and therefore had none of the rest.
|
||||
*
|
||||
* These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel
|
||||
* server-side: the relay writes you into the kind-39002 roster and you can immediately read and post,
|
||||
@@ -93,125 +66,22 @@ fun ChannelInvitesSection(
|
||||
|
||||
Column(modifier) {
|
||||
invites.forEach { invite ->
|
||||
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
|
||||
// below it. Without a key Compose matches children by position and each shifted row would
|
||||
// recompose against a different invite — re-resolving the actor and reloading their avatar.
|
||||
key(invite.channelId) {
|
||||
ChannelInviteCompose(invite, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
// Keyed by the kind-44100 it came from: the list is sorted newest-first, so an arriving
|
||||
// invite shifts every row below it. Without a key Compose matches children by position and
|
||||
// each shifted row would recompose against a different note.
|
||||
key(invite.eventId) {
|
||||
val note = remember(invite.eventId) { LocalCache.getNoteIfExists(invite.eventId) }
|
||||
if (note != null) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = 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 ChannelInviteCompose(
|
||||
invite: BuzzChannelInvite,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val baseChannel =
|
||||
remember(invite.channelId, invite.relay) {
|
||||
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
|
||||
}
|
||||
|
||||
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
|
||||
// helper also registers a ChannelFinder query, and every assembler under it is gated on
|
||||
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
|
||||
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
|
||||
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
|
||||
// nothing here opens the channel's *message* subscription — holding that back until the viewer
|
||||
// answers is the whole point of the prompt.
|
||||
val channelState by
|
||||
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
|
||||
// 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 =
|
||||
remember(backgroundColor) {
|
||||
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".
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
|
||||
// dotted variant's own leading space would double it. Same choice the note header makes.
|
||||
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
|
||||
}
|
||||
},
|
||||
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(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.
|
||||
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
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.add_to_messages), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2888,6 +2888,7 @@
|
||||
<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_unknown_actor">Someone</string>
|
||||
<string name="channel_invite_added_you">added you to this channel</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>
|
||||
|
||||
Reference in New Issue
Block a user