Merge remote-tracking branch 'origin/main' into claude/push-notification-design-fzyutz

This commit is contained in:
Claude
2026-07-23 20:00:26 +00:00
389 changed files with 28792 additions and 295 deletions
+11
View File
@@ -212,6 +212,17 @@
<data android:pathPrefix="/invite/" />
</intent-filter>
<!-- Buzz workspace invite links: https://<team>.communities.buzz.xyz/invite/<token> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="communities.buzz.xyz" />
<data android:host="*.communities.buzz.xyz" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -46,6 +46,9 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
@@ -270,6 +273,18 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
@@ -153,6 +154,16 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
@@ -207,8 +218,11 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -222,6 +236,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
@@ -2279,12 +2294,25 @@ class Account(
gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group ->
val hostRelay = group.groupId.relayUrl
val signed =
signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
},
)
if (BuzzRelayDialect.isBuzz(hostRelay)) {
// Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's
// root (never `broadcast` — a minichat reply always lives in the thread).
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
signer.sign(
StreamMessageV2Event.build(group.groupId.id, text) {
buzzThread(root, rootEvent.id)
rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) }
previous(group.previousEventRefs(pubKey))
},
)
} else {
signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
},
)
}
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(hostRelay))
return true
@@ -2912,6 +2940,76 @@ class Account(
follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!isWriteable()) return
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/**
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
* We never assign the channel id ourselves, so callers discover the materialized DM
* by watching that registry rather than from this call's return.
*/
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
): String? {
val signed = signer.sign(DmOpenEvent.build(participants))
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
var results = client.publishAndCollectResults(signed, setOf(relay))
var channelId = buzzDmChannelIdFromAck(results)
// NIP-42 write race: on a cold connection the relay rejects the first publish with
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = client.publishAndCollectResults(signed, setOf(relay))
channelId = buzzDmChannelIdFromAck(results)
}
return channelId
}
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
results.values
.firstOrNull { it.accepted }
?.message
?.substringAfter("\"channel_id\":\"", "")
?.substringBefore('"')
?.takeIf { it.isNotBlank() }
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
val template = DmHideEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
suspend fun addBuzzDmMember(
channel: RelayGroupChannel,
member: HexKey,
) {
val template = DmAddMemberEvent.build(channel.groupId.id, member)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
@@ -3052,6 +3150,28 @@ class Account(
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
* [relay] with no channel scope.
*/
suspend fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
}
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
suspend fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
@@ -28,6 +28,12 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -50,6 +56,83 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
import com.vitorpamplona.quartz.buzz.presence.PresenceUpdateEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
@@ -197,6 +280,9 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
@@ -644,6 +730,18 @@ object LocalCache : ILocalCache, ICacheProvider {
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
/**
* The [RelayGroupChannel] a group-scoped content [note] belongs to, resolved the same way
* [attachToRelayGroupIfScoped] keyed it: the serving-relay key first (fast O(1)), then the single
* channel bearing this group id when the note has no usable provenance relay. Read-only — used by
* feed filters that need a note's channel without scanning every channel's timeline.
*/
fun getRelayGroupChannelForContent(note: Note): RelayGroupChannel? {
val groupId = note.event?.groupId() ?: return null
note.relays.firstNotNullOfOrNull { getRelayGroupChannelIfExists(GroupId(groupId, it)) }?.let { return it }
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
}
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -1151,6 +1249,12 @@ object LocalCache : ILocalCache, ICacheProvider {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// A Buzz thread reply links to the message it answers (its `reply`-marked e-tag) so it
// lands in that message's replies (the minichat). A plain message / non-reply has no marker.
listOfNotNull(event.tags.buzzThreadReply()?.let { checkGetOrCreateNote(it) })
}
is VoiceReplyEvent -> {
event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -1257,6 +1361,16 @@ object LocalCache : ILocalCache, ICacheProvider {
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
// buzz-sdk). Link both so inbound replies render their quote bubble —
// we emit these markers on send, so we must also read them.
listOfNotNull(
event.tags.buzzThreadRoot(),
event.tags.buzzThreadReply(),
).distinct().mapNotNull { checkGetOrCreateNote(it) }
}
else -> {
emptyList()
}
@@ -1742,10 +1856,26 @@ object LocalCache : ILocalCache, ICacheProvider {
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
// Every report that names an author also lands in the naming index, including those
// filed against a note rather than a profile. Read only by the DM sender warning — the
// hide threshold keeps counting `receivedReportsByAuthor` alone.
if (eventsReported.isEmpty()) {
authorsReported.forEach { author -> author.reports().addReport(note) }
authorsReported.forEach { author ->
val reports = author.reports()
reports.addReport(note)
reports.addReportNamingUser(note)
}
} else {
eventsReported.forEach { it.addReport(note) }
// Index only the authors whose own `p` tag carries a report type: an event-scoped
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
// and there is no threshold here to absorb that noise the way
// `receivedReportsByAuthor`'s hide path does.
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
authorsReported.forEach { author ->
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
}
}
}
@@ -1986,6 +2116,170 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Buzz/NIP-43 community (relay-wide) membership snapshot (kind 13534) → the relay's community roster.
* Buzz grants participation across ALL its channels off this list, not the per-channel 39002, so
* feeding it into [BuzzCommunityMembership] lets [RelayGroupChannel.membershipOf] recognise a
* community member even when a channel roster omits them. Scoped to Buzz relays (the concept is
* Buzz-specific and the event is relay-signed + NIP-70 relay-only).
*/
fun consume(
event: RelayMembershipListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.updateSnapshot(relay, event.members().toSet(), event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/** Buzz/NIP-43 community member-added delta (kind 8000) → adds to the relay's [BuzzCommunityMembership]. */
fun consume(
event: RelayAddMemberEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBuzzRegularEvent(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.applyDelta(relay, add = event.memberPubKeys().toSet(), createdAt = event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/** Buzz/NIP-43 community member-removed delta (kind 8001) → removes from the relay's [BuzzCommunityMembership]. */
fun consume(
event: RelayRemoveMemberEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBuzzRegularEvent(event, relay, wasVerified)
if (relay != null && BuzzRelayDialect.isBuzz(relay)) {
if (BuzzCommunityMembership.applyDelta(relay, remove = event.memberPubKeys().toSet(), createdAt = event.createdAt)) {
refreshRelayGroupMembership(relay)
}
}
return new
}
/**
* Poke every relay-group channel on [relay] so the `membershipOf` community-level fallback re-renders
* (community membership lives outside the per-channel roster, so nothing else invalidates their state).
*/
private fun refreshRelayGroupMembership(relay: NormalizedRelayUrl) {
getRelayGroupChannelsOnRelay(relay).forEach { it.updateChannelInfo() }
}
/**
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
* The note-has-event check is the same verification gate the attach path uses.
*/
private fun markBuzzIfVerified(
event: Event,
relay: NormalizedRelayUrl?,
) {
if (relay != null && getNoteIfExists(event.id)?.event != null) {
BuzzRelayDialect.mark(relay)
}
}
/**
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
* exist on `block/buzz` relays, so their (verified) arrival IS the dialect
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
* share one timeline and non-host strays never mint phantom channels.
*/
private fun consumeBuzzTimelineEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachToRelayGroupIfScoped(event, relay)
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
}
/**
* A Buzz forum ROOT post (kind 45001): a thread, not a chat message. Route it to the group's
* Threads collection ([RelayGroupChannel.addThread]) — the same place kind-11 threads go — so it
* surfaces in the forum/Threads view instead of leaking into the kind-9 chat feed as a bubble.
* Its kind-45003 comments are stored (store-only) and loaded on demand by the thread detail.
*/
private fun consumeBuzzForumPost(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachThreadToRelayGroupIfScoped(event, relay)
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and record the
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
// land too), but do NOT attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val channelId = event.channel() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
}
}
private fun consume(
event: DmVisibilityEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The relay-signed, `#p`-gated per-viewer hidden-DM snapshot (30622). Store the
// addressable event AND mirror the viewer's hidden set into the registry so a
// hidden DM drops out of that viewer's list until it's re-opened.
consumeBaseReplaceable(event, relay, wasVerified).also {
val viewer = event.viewer().ifBlank { return@also }
BuzzDmRegistry.recordHidden(viewer, event.hiddenChannels().toSet())
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The canvas is the channel's single living document, not a chat row: track
// only the newest revision as overlay state, never attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val channelId = event.channel() ?: return@also
val note = getOrCreateNote(event.id)
if (note.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).updateCanvas(note)
}
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -3040,6 +3334,13 @@ object LocalCache : ILocalCache, ICacheProvider {
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
}
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
// message was just pruned, else they pin the edit note + author forever.
if (channel is RelayGroupChannel) {
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
@@ -3268,7 +3569,10 @@ object LocalCache : ILocalCache, ICacheProvider {
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
}
noteEvent.reportedPost().forEach {
@@ -4407,6 +4711,121 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// ------------------------------------------------------------------
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
// rest are stored for query/state. Kinds 9041/39005/49001 are absent
// on purpose: their numbers belong to GoalEvent, GroupPinnedEvent and
// a non-wire audit kind. Kind 20001 is shared with GeohashPresenceEvent
// (BitChat); EventFactory routes it to PresenceUpdateEvent only when the
// BitChat `g` tag is absent, and it is handled below with the ephemerals.
// ------------------------------------------------------------------
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Buzz addressable/replaceable state.
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consume(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
// Buzz store-only regular kinds (queryable state; no timeline row yet).
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayMembershipListEvent -> consume(event, relay, wasVerified)
is RelayAddMemberEvent -> consume(event, relay, wasVerified)
is RelayRemoveMemberEvent -> consume(event, relay, wasVerified)
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz relay-signed sidecars and audit projections: store-only, queryable.
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
// pollute the note store, and do NOT mark the dialect from them (they are
// never stored, so the verified-mark gate has nothing to check).
is TypingIndicatorEvent -> {
// Live "who is typing" side-effect only — record the heartbeat into the
// process-wide typing state (the channel view collects it) and return
// false so it never becomes a feed row. Own-typing is filtered in the UI.
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
false
}
is PresenceUpdateEvent -> {
// Live online/away/offline side-effect only — record the latest status for
// the subject (the `p` tag on relay-synthesized reads, else the author) into
// the process-wide presence state and return false so it never becomes a row.
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
false
}
is ObserverFrameEvent -> false
is HuddleReactionEvent -> false
// Pairing (24134) is deliberately dialect-neutral: it flows during device
// pairing before any workspace relationship is established.
is PairingEvent -> false
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
@@ -36,6 +36,18 @@ class Nip11CachedRetriever(
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever(okHttpClient)
/**
* Drops any cached NIP-11 document (including a cached *error*) for [relay], so the next
* [loadRelayInfo] performs a fresh fetch. Used when the transport to a relay changes — e.g. it's
* marked Trusted to move off Tor onto clearnet — so a doc that failed over the old transport
* (403/timeout) isn't served from cache for the rest of its TTL, which would keep the relay's
* `self` key unknown and hide its relay-signed NIP-29 groups.
*/
fun invalidate(relay: NormalizedRelayUrl) {
relayInformationDocumentCache.remove(relay)
relayInformationEmptyCache.remove(relay)
}
fun trimToSize(maxItems: Int) {
relayInformationDocumentCache.trimToSize(maxItems)
// relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders;
@@ -0,0 +1,107 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
* pubkey each attestation authorizes).
*
* On construction it loads the saved entries into the singleton — **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Entry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
}
}
@@ -0,0 +1,76 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
}
}
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -0,0 +1,88 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
* read/write by the authenticated key).
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
}
}
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error writing joined workspaces: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}
@@ -0,0 +1,105 @@
/*
* 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.service.buzz
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Mints a `block/buzz` workspace invite link by POSTing to the relay's **Buzz-specific**
* `/api/invites` HTTP endpoint (NIP-98 signed; owner/admin only). Not a Nostr event and not a NIP —
* only the NIP-98 auth is standard — so this is Buzz-only and lives outside quartz.
*
* The endpoint host is the relay's own host (the invite link is `https://<host>/invite/<code>`), so
* we never need to own a domain — the relay does, and it checks the signer is an owner/admin. See
* `crates/buzz-relay/src/api/invites.rs`.
*/
object BuzzInviteMinter {
private val json = ObjectMapper()
/** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */
data class MintedInvite(
val code: String,
val url: String,
val expiresAt: Long,
)
/**
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d];
* default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient]
* supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is
* reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure.
*/
suspend fun mint(
relay: NormalizedRelayUrl,
ttlSecs: Long?,
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
): MintedInvite =
withContext(Dispatchers.IO) {
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
val wsUrl = relay.url
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
val host = wsUrl.substringAfter("://").substringBefore("/")
// Parse once into an HttpUrl and sign over ITS canonical string, so the NIP-98 `u` tag is
// byte-for-byte what OkHttp transmits (host casing / encoding can't drift the two apart).
val httpUrl = "$scheme://$host/api/invites".toHttpUrl()
val url = httpUrl.toString()
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}"
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
val auth = httpAuth(url, "POST", bodyBytes)
val request =
Request
.Builder()
.url(httpUrl)
.addHeader("Authorization", auth.toAuthToken())
.post(bodyBytes.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient(url).newCall(request).execute().use { response ->
val payload = response.body.string()
val tree = runCatching { json.readTree(payload) }.getOrNull()
if (!response.isSuccessful) {
val slug = tree?.get("error")?.asText() ?: "HTTP ${response.code}"
throw IllegalStateException(slug)
}
MintedInvite(
code = tree?.get("code")?.asText().orEmpty(),
url = tree?.get("url")?.asText().orEmpty(),
expiresAt = tree?.get("expires_at")?.asLong() ?: 0L,
)
}
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -85,6 +86,16 @@ class RelayProxyClientConnector(
// one relay serve out its delay.
private var lastTorSettings: TorRelaySettings? = null
// The relay-classification sets (trusted/DM/money) from the last apply(). A relay moving into or
// out of one of these flips its transport (e.g. marking a relay Trusted moves it off Tor onto
// clearnet) WITHOUT changing TorRelaySettings, so `torPolicyChanged` above misses it and the
// flipped relay would sit out its (now-irrelevant) backoff. We track these so such a relay can
// skip its retry delay on the next reconnect — scoped to onlyIfChanged, so only the relays that
// actually flipped re-dial and the rest of the pool's backoff is left untouched.
private var lastTrustedRelays: Set<NormalizedRelayUrl>? = null
private var lastDmRelays: Set<NormalizedRelayUrl>? = null
private var lastMoneyOpRelays: Set<NormalizedRelayUrl>? = null
@OptIn(FlowPreview::class)
val relayServices =
combine(
@@ -150,6 +161,9 @@ class RelayProxyClientConnector(
lastTorSettings = torSettings
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
lastTrustedRelays = infra.evaluator.trustedRelayList
lastDmRelays = infra.evaluator.dmRelayList
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
}
else -> {
@@ -170,12 +184,28 @@ class RelayProxyClientConnector(
// relays go through Tor. The relays whose transport flipped must re-dial now.
val torPolicyChanged = lastTorSettings != null && torSettings != lastTorSettings
// A relay moved into/out of the trusted/DM/money sets (e.g. marked Trusted to move it
// off Tor onto clearnet). That flips only that relay's transport, not TorRelaySettings,
// so let onlyIfChanged pick out the flipped relay(s) and skip THEIR retry delay —
// without resetBackoff(), so the rest of the pool's backoff is untouched (these sets
// churn while relay lists load, and forgiving the whole pool then would be too much).
val classificationChanged =
lastTrustedRelays != null &&
(
infra.evaluator.trustedRelayList != lastTrustedRelays ||
infra.evaluator.dmRelayList != lastDmRelays ||
infra.evaluator.moneyOpRelayList != lastMoneyOpRelays
)
val previousNetworkId = lastNetworkId
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
lastNetworkId = networkId ?: lastNetworkId
lastTorSettings = torSettings
lastTrustedRelays = infra.evaluator.trustedRelayList
lastDmRelays = infra.evaluator.dmRelayList
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
if (networkChanged) {
Log.d("ManageRelayServices") {
@@ -196,11 +226,12 @@ class RelayProxyClientConnector(
Log.d("ManageRelayServices") {
"Relay Services have changed, reconnecting relays that need to " +
"(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged)"
"(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged " +
"classificationChanged=$classificationChanged)"
}
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = freshStart,
ignoreRetryDelays = freshStart || classificationChanged,
)
}
}
@@ -21,6 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
@@ -135,7 +138,7 @@ class AuthCoordinator(
// Remember why we granted this relay so the settings screen can explain it.
account.relayAuthLedger.recordGrant(context)
try {
signed.add(account.signer.sign(authTemplate))
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
}
@@ -169,6 +172,27 @@ class AuthCoordinator(
}
}
/**
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
* appended — so the relay grants virtual membership to an un-enrolled agent key while
* its owner stays a member. Otherwise returns [template] unchanged.
*
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
* add an `auth` tag where one isn't wanted.
*/
private fun buzzAugmented(
template: EventTemplate<RelayAuthEvent>,
accountPubKey: HexKey,
relayUrl: NormalizedRelayUrl,
): EventTemplate<RelayAuthEvent> {
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
@@ -187,12 +211,17 @@ class AuthCoordinator(
account: Account,
relayUrl: NormalizedRelayUrl,
): Boolean =
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
)
// A Buzz workspace the user explicitly joined is a first-party reason to authenticate: its
// channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
// first-party, so without this the p-gated 44100/30622 reads would never be served and the
// workspace would stay empty.
BuzzWorkspaces.isJoined(relayUrl) ||
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
)
fun destroy() {
receiver.destroy()
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStateFilterAssembler
@@ -139,6 +140,7 @@ class RelaySubscriptionsCoordinator(
// amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md).
val relayGroupJoinedState = RelayGroupJoinedStateFilterAssembler(client) // always-on: joined groups' metadata/roster/roles/pins
val relayGroupJoinedChatTail = RelayGroupJoinedChatTailFilterAssembler(client) // always-on: batched #h recent-tail for Messages previews
val buzzDmJoinedChatTail = BuzzDmJoinedChatTailFilterAssembler(client) // always-on: batched #h recent-tail for the viewer's Buzz DM channels
val relayGroupOpenChatTail = RelayGroupOpenChatTailFilterAssembler(client) // the open group's recent chat (covers non-joined)
val relayGroupOpenChatHistory = RelayGroupOpenChatHistoryFilterAssembler(client) // the open group's on-demand backward history pager
@@ -218,6 +220,7 @@ class RelaySubscriptionsCoordinator(
relayGroupsDiscovery,
relayGroupJoinedState,
relayGroupJoinedChatTail,
buzzDmJoinedChatTail,
relayGroupOpenChatTail,
relayGroupOpenChatHistory,
concordChannels,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
@@ -99,7 +101,28 @@ class AccountNotificationsEoseFromInboxRelaysManager(
)
}
return inbox + groups
// Buzz DM channels are NOT in the published group list (membership is server-side, tracked in
// BuzzDmChannels), so the joined-group query above skips them. Poll each DM host relay the same
// way — `#p` = me + `#h` = my DM channels — so a reaction/zap/repost on my DM message surfaces
// in notifications, not only as a chip inside the open conversation. Hidden DMs are excluded.
val myPubkey = user(key).pubkeyHex
val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey)
val dmGroups =
BuzzDmChannels
.channelsFor(myPubkey)
.filterKeys { it !in hiddenDms }
.entries
.groupBy({ it.value }, { it.key })
.flatMap { (relay, channelIds) ->
filterGroupNotificationsToPubkey(
relay = relay,
pubkey = myPubkey,
groupIds = channelIds.distinct(),
since = since?.get(relay)?.time ?: pagingBoundary,
)
}
return inbox + groups + dmGroups
}
val userJobMap = mutableMapOf<User, List<Job>>()
@@ -122,6 +145,14 @@ class AccountNotificationsEoseFromInboxRelaysManager(
invalidateFilters()
}
},
// Re-subscribe when a Buzz DM is discovered/hidden so its host relay is polled for
// reactions/zaps on my DM messages.
key.account.scope.launch(Dispatchers.IO) {
BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
@@ -82,12 +84,26 @@ class AccountNotificationsHistoryEoseManager(
val status: StateFlow<PagingStatus> = pager.status
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
// group filter and (its keys) to add group host relays to the paged relay set.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
account.relayGroupList.liveRelayGroupList.value
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
.toMap()
// group filter and (its keys) to add group host relays to the paged relay set. Buzz DM channels aren't
// in the published list (server-side membership, tracked in BuzzDmChannels), so include them here too —
// otherwise paging back never loads older reactions/zaps on my DM messages. Hidden DMs are excluded.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> {
val myPubkey = account.userProfile().pubkeyHex
val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey)
val listGroups =
account.relayGroupList.liveRelayGroupList.value
.mapNotNull { tag -> RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { it to tag.groupId } }
val dmGroups =
BuzzDmChannels
.channelsFor(myPubkey)
.filterKeys { it !in hiddenDms }
.map { (channelId, relay) -> relay to channelId }
return (listGroups + dmGroups)
.groupBy({ it.first }, { it.second })
.mapValues { (_, ids) -> ids.distinct() }
}
// The full relay set this account pages notifications back through: inbox relays + group host relays.
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
@@ -159,6 +175,13 @@ class AccountNotificationsHistoryEoseManager(
.sample(1000)
.collectLatest { invalidateFilters() }
},
// A Buzz DM discovered/hidden adds or drops its host relay from the paged set.
key.account.scope.launch(Dispatchers.IO) {
BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
@@ -30,10 +30,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
@@ -240,11 +240,11 @@ fun observeNoteMinichatReplyCount(
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } }
.mapLatest { it.note.replies.count { reply -> isMinichatReply(reply.event) } }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent })
return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) })
}
@Composable
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -233,6 +234,7 @@ fun uriToRoute(
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
buzzInviteRoute(uri)?.let { return it }
val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
@@ -384,3 +386,15 @@ private fun concordInviteRoute(uri: String): Route? =
} else {
null
}
/**
* A Buzz workspace invite (`https://<host>/invite/<token>`) — a plain `/invite/` https URL with
* no fragment, so disjoint from the Concord shape above. Opens the in-app join flow
* ([Route.BuzzInvite]) instead of the external browser so the claim signs with the user's key.
*/
private fun buzzInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && BuzzInviteLink.parse(uri) != null) {
Route.BuzzInvite(uri)
} else {
null
}
@@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
@@ -218,6 +219,7 @@ class AmethystRichTextSegmentRenderer(
} else {
ClickableConcordInviteLink(segment.segmentText, nav)
}
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(segment.segmentText, nav)
else -> Text(segment.segmentText)
}
}
@@ -0,0 +1,75 @@
/*
* 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.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import kotlinx.coroutines.launch
/**
* Renders a Buzz workspace invite link (`https://<host>/invite/<token>`) inline as a tappable
* link that opens the in-app join flow ([Route.BuzzInvite], which confirms the workspace and
* hands off to the `window.nostr` browser for the policy + NIP-98 claim) instead of the
* external browser. Long-press copies the full link. Falls back to plain text if the literal
* can't be parsed (detection should guarantee it does).
*/
@Composable
fun ClickableBuzzInviteLink(
linkText: String,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val parsed = remember(linkText) { BuzzInviteLink.parse(linkText) }
if (parsed == null) {
Text(text = linkText)
return
}
val clickableModifier =
remember(linkText) {
Modifier.combinedClickable(
onLongClick = { scope.launch { clipboardManager.setText(linkText) } },
onClick = { nav.nav(Route.BuzzInvite(linkText)) },
)
}
Text(
text = linkText,
modifier = clickableModifier,
color = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
@@ -492,6 +493,7 @@ private fun RenderWordWithoutPreview(
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav)
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
@@ -533,6 +535,7 @@ private fun RenderWordWithPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav)
is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav)
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
@@ -99,6 +99,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -453,9 +462,14 @@ fun BuildNavigation(
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzNewDm> { BuzzNewDmScreen(it.relayUrl, accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentConsole> { AgentConsoleScreen(it.relayUrl, accountViewModel, nav) }
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
@@ -731,6 +745,8 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.BuzzInvite> { BuzzInviteScreen(it.link, accountViewModel, nav) }
composableFromEnd<Route.Concords> { ConcordHomeScreen(accountViewModel, nav) }
composableFromEnd<Route.ConcordCreate> { ConcordCreateScreen(accountViewModel, nav) }
@@ -752,6 +768,9 @@ fun BuildNavigation(
nav = nav,
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzForumThread> { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
@@ -356,6 +356,16 @@ fun routeReplyTo(
return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId)
}
// A Buzz/NIP-29 relay-group message (kind-9 or kind-40002, e.g. a Buzz DM shown on the Notifications
// tab) must reply INSIDE the group, not as a public kind:1111 comment. The group has no hint on the
// inner event — detect it via the gathering RelayGroupChannel (recorded on consume), mirroring the
// Marmot/Concord cases above — and open the conversation, where the composer sends the Buzz-dialect
// reply. Without this it falls through to `else` = Route.GenericCommentPost (kind:1111).
val relayGroup = note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
if (relayGroup != null) {
return routeFor(relayGroup)
}
val noteEvent = note.event
return when (noteEvent) {
is ChannelMessageEvent -> {
@@ -469,6 +469,17 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable
data class AgentConsole(
val relayUrl: String,
) : Route()
@Serializable object AgentAttestation : Route()
@Serializable data class AgentPersonaEdit(
val slug: String? = null,
) : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()
@@ -691,6 +702,22 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable data class BuzzCanvas(
val channelId: String,
val relayUrl: String,
) : Route()
@Serializable data class BuzzForumPost(
val channelId: String,
val relayUrl: String,
) : Route()
@Serializable data class BuzzForumThread(
val channelId: String,
val relayUrl: String,
val rootId: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -702,6 +729,16 @@ sealed class Route {
@Serializable object RelayGroups : Route()
@Serializable
data class BuzzDmList(
val relayUrl: String,
) : Route()
@Serializable
data class BuzzNewDm(
val relayUrl: String,
) : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id
@@ -733,6 +770,10 @@ sealed class Route {
val link: String,
) : Route()
@Serializable data class BuzzInvite(
val link: String,
) : Route()
@Serializable object Concords : Route()
// The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
@@ -177,6 +178,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayGroupMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
@@ -225,6 +227,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.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1445,16 +1448,45 @@ private fun RenderNoteRow(
}
is ChatEvent -> {
RenderChat(
// A kind-9 that belongs to a Buzz/NIP-29 relay group (its channel is recorded as a gatherer
// on consume) renders as a conversation row with a group/DM header — so a Buzz DM on the
// Notifications tab reads like a message, not a post. A non-group kind-9 keeps the plain chat body.
if (baseNote.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } != null) {
RenderRelayGroupMessage(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else {
RenderChat(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
accountViewModel,
nav,
isBoostedNote = isBoostedNote,
)
}
}
is StreamMessageV2Event -> {
RenderRelayGroupMessage(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
isBoostedNote = isBoostedNote,
)
}
@@ -114,6 +114,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
@@ -265,6 +266,10 @@ private fun InnerReactionRow(
// gift-wraps reactions to empty-sig targets, and zaps are forced to
// the PRIVATE type with public rails suppressed.
val isPrivateRumor = baseNote.isPrivateRumor()
// A NIP-29/Buzz relay-group message (including a Buzz DM) must not be publicly reposted or
// shared: the reference points at a membership-gated group event that non-members can't fetch,
// and a DM shouldn't be rebroadcast at all. Reply/like/zap stay (reply routes into the group).
val isRelayGroupMessage = baseNote.inGatherers?.any { it is RelayGroupChannel } == true
when (item.action) {
ReactionRowAction.Reply -> {
ReplyReactionWithDialog(
@@ -279,7 +284,7 @@ private fun InnerReactionRow(
ReactionRowAction.Boost -> {
val isDM = baseNote.event is ChatroomKeyable
if (!isDM && !isPrivateRumor) {
if (!isDM && !isPrivateRumor && !isRelayGroupMessage) {
BoostWithDialog(
baseNote,
editState,
@@ -315,7 +320,7 @@ private fun InnerReactionRow(
}
ReactionRowAction.Share -> {
if (!isPrivateRumor) {
if (!isPrivateRumor && !isRelayGroupMessage) {
ShareReaction(
note = baseNote,
nav = nav,
@@ -0,0 +1,162 @@
/*
* 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.Row
import androidx.compose.foundation.layout.Spacer
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/**
* Renders a Buzz relay-group message (a kind-9 [ChatEvent] or a kind-40002 stream message) as a
* conversation row rather than a standalone post — the group/DM analog of [RenderChatMessage] /
* [RenderChannelMessage]. Draws a [RelayGroupChannelHeader] (which for a DM titles by the other
* participant) above the message body, so a Buzz DM on the Notifications tab reads like a message.
*
* The channel is taken from the note's gatherers, where [com.vitorpamplona.amethyst.commons.model.Channel.addNote]
* records it on consume; without it (a stray message we never attached) we just render the body.
*/
@Composable
fun RenderRelayGroupMessage(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channel = remember(note) { note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } }
channel?.let {
RelayGroupChannelHeader(
channel = it,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
// Body: kind-9 has its own renderer (its reply target is a `q`/`e` tag, not a NIP-10 thread);
// everything else (kind-40002 stream messages) goes through the generic text renderer. Both pass
// NONE so the header — not an inline reply-to preview — is the only chrome above the text.
if (note.event is ChatEvent) {
RenderChat(note, makeItShort, canPreview, quotesLeft, ReplyRenderType.NONE, backgroundColor, accountViewModel, nav)
} else {
RenderTextEvent(note, makeItShort, canPreview, quotesLeft, ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, nav)
}
}
/** A compact, tappable header naming the Buzz group (or DM participant) a message belongs to. */
@Composable
fun RelayGroupChannelHeader(
channel: RelayGroupChannel,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isDm = channel.event?.isBuzzDm() == true
val dmOther =
remember(channel) {
if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != accountViewModel.userProfile().pubkeyHex } else null
}
Row(
modifier = modifier.fillMaxWidth().clickable { nav.nav(routeFor(channel)) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
modifier = Modifier.size(28.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
if (dmOther != null) {
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f))
} else {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
/** The other DM participant's display name (reactive), falling back to the channel name. */
@Composable
private fun RelayGroupDmName(
otherPubkey: HexKey,
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
modifier: Modifier,
) {
val user = remember(otherPubkey) { LocalCache.getOrCreateUser(otherPubkey) }
val name by observeUserName(user, accountViewModel)
Text(
text = name.ifBlank { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
@@ -26,17 +26,14 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.replyModifier
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun RenderReport(
@@ -54,35 +51,11 @@ fun RenderReport(
.mapTo(LinkedHashSet()) { it.type }
}
val explicitContent = stringRes(R.string.explicit_content)
val nudity = stringRes(R.string.nudity)
val profanity = stringRes(R.string.profanity_hateful_speech)
val spam = stringRes(R.string.spam)
val impersonation = stringRes(R.string.impersonation)
val illegal = stringRes(R.string.illegal_behavior)
val malware = stringRes(R.string.malware)
val other = stringRes(R.string.other)
val harassment = stringRes(R.string.harassment)
val violence = stringRes(R.string.violence)
val reportTypeLabels = reportTypes.map { reportTypeLabel(it) }
val content =
remember(reportTypes, noteEvent) {
val reportTypeText =
reportTypes.joinToString(", ") {
when (it) {
ReportType.EXPLICIT -> explicitContent
ReportType.NUDITY -> nudity
ReportType.PROFANITY -> profanity
ReportType.SPAM -> spam
ReportType.IMPERSONATION -> impersonation
ReportType.ILLEGAL -> illegal
ReportType.MALWARE -> malware
ReportType.OTHER -> other
ReportType.HARASSMENT -> harassment
ReportType.VIOLENCE -> violence
null -> other
}
}
remember(reportTypeLabels, noteEvent) {
val reportTypeText = reportTypeLabels.joinToString(", ")
val extra = noteEvent.content.ifBlank { null }?.let { ": $it" } ?: ""
reportTypeText + extra
}
@@ -0,0 +1,50 @@
/*
* 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.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip56Reports.ReportType
/**
* [reportTypeLabel] for each reason. Resolved into a list first because `joinToString` is not
* inline and so cannot call a composable, while `map` is.
*/
@Composable
fun reportTypeLabels(types: Collection<ReportType?>): List<String> = types.map { reportTypeLabel(it) }
/** The localized name of a NIP-56 report reason. An unrecognized reason reads as "Other". */
@Composable
fun reportTypeLabel(type: ReportType?): String =
when (type) {
ReportType.EXPLICIT -> stringRes(R.string.explicit_content)
ReportType.NUDITY -> stringRes(R.string.nudity)
ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech)
ReportType.SPAM -> stringRes(R.string.spam)
ReportType.IMPERSONATION -> stringRes(R.string.impersonation)
ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior)
ReportType.MALWARE -> stringRes(R.string.malware)
ReportType.OTHER -> stringRes(R.string.other)
ReportType.HARASSMENT -> stringRes(R.string.harassment)
ReportType.VIOLENCE -> stringRes(R.string.violence)
null -> stringRes(R.string.other)
}
@@ -49,6 +49,8 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportWarningState
import com.vitorpamplona.amethyst.commons.model.nip56Reports.dmReportWarningFor
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
@@ -697,6 +699,11 @@ class AccountViewModel(
account.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,
@@ -786,6 +793,38 @@ class AccountViewModel(
noteIsHiddenFlows.put(note, it)
}
private val userReportWarningFlows = LruCache<User, StateFlow<UserReportWarningState>>(300)
/**
* Whether to warn about [user] as the counterpart of a 1:1 DM, and with what.
*
* Safe to cache: [User.reports] is never nulled once created, so the upstream this subscribes to
* survives the idle gaps that `WhileSubscribed` introduces.
*/
fun createUserReportWarningFlow(user: User): StateFlow<UserReportWarningState> =
userReportWarningFlows.get(user)
?: combineTransform(
user.reports().reportsNamingUser,
account.kind3FollowList.flow,
account.settings.syncedSettings.security.warnAboutPostsWithReports,
) { _, followList, warnAboutReports ->
emit(
dmReportWarningFor(
counterpart = user,
loggedInPubKey = account.signer.pubKey,
followingKeySet = followList.authors,
warnAboutReports = warnAboutReports,
),
)
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(10000, 10000),
UserReportWarningState.SILENT,
).also {
userReportWarningFlows.put(user, it)
}
private val noteMustShowExpandButtonFlows = LruCache<Note, StateFlow<Boolean>>(300)
fun createMustShowExpandButtonFlows(note: Note): StateFlow<Boolean> =
@@ -1679,6 +1718,19 @@ class AccountViewModel(
roles: List<String>,
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
/** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */
fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) = launchSigner { account.addCommunityMember(relay, pubkey, role) }
/** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */
fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
@@ -51,7 +51,9 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmDiscoveryPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStatePreload
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
@@ -103,6 +105,11 @@ fun LoggedInPage(
RelayGroupJoinedStatePreload(accountViewModel)
RelayGroupJoinedChatTailPreload(accountViewModel)
// Discover the viewer's Buzz DM channels (44100 #p=me) across joined workspaces and keep their
// messages warm app-wide, so a Buzz DM shows on the Notifications tab / in push without opening it.
BuzzDmDiscoveryPreload(accountViewModel)
BuzzDmJoinedChatTailPreload(accountViewModel)
// Foreground-only loaders: follows-outbox finder + random-relay notifications.
// Pauses on ON_STOP, resumes on ON_START.
AccountForegroundFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,469 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
/**
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
* events and the relay grants it virtual membership while the owner stays a member.
*
* This is entirely offline: the signature covers a hashed commitment string, not a
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
* external signer cannot produce it. Read-only accounts see an explanation instead of
* the form. Nothing is published; the signed tag is a credential the owner hands to the
* agent operator out-of-band.
*/
@Composable
fun AgentAttestationScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val keyPair = accountViewModel.account.settings.keyPair
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
Scaffold(
topBar = { TopBarWithBackButton("Attestations", nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Agent side: hold an attestation an owner gave you, so this account
// authenticates to the owner's Buzz relays as a virtual member. Available to
// any signer — holding a credential doesn't require the raw key.
HoldAttestationSection(myPubkey = myPubkey)
// Owner side: issue an attestation for an agent key. Needs the raw private key.
val privKey = keyPair.privKey
if (privKey == null) {
ReadOnlyKeyNotice()
} else {
AttestationForm(ownerKey = keyPair)
}
}
}
}
/**
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
* restarts (device-global) by `BuzzAttestationPreferences`.
*/
@Composable
private fun HoldAttestationSection(myPubkey: String) {
val held by BuzzHeldAttestations.flow.collectAsState()
val mine = held[myPubkey]
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Hold an attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (mine != null) {
Text(
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
Text("Remove")
}
} else {
Text(
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
label = { Text("auth tag JSON") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
}
Button(
onClick = {
when (val outcome = parseHeldAttestation(input, myPubkey)) {
is HoldOutcome.Failure -> error = outcome.message
is HoldOutcome.Success -> {
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
input = ""
error = null
}
}
},
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Hold attestation")
}
}
}
}
}
private sealed interface HoldOutcome {
data class Success(
val attestation: OwnerAttestation,
) : HoldOutcome
data class Failure(
val message: String,
) : HoldOutcome
}
/**
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
* non-`auth` tag, or a signature that doesn't verify for this key.
*/
private fun parseHeldAttestation(
input: String,
myPubkey: String,
): HoldOutcome {
val tag =
try {
Json
.parseToJsonElement(input.trim())
.jsonArray
.map { it.jsonPrimitive.content }
.toTypedArray()
} catch (e: Exception) {
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
}
val attestation =
OwnerAttestation.parse(tag)
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
if (!attestation.verify(myPubkey)) {
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
}
return HoldOutcome.Success(attestation)
}
@Composable
private fun ReadOnlyKeyNotice() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Local key required",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
"so it can only be produced by a signer that holds your raw private key. This " +
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
"sign an attestation.",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun AttestationForm(ownerKey: KeyPair) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var agentInput by remember { mutableStateOf("") }
var kindInput by remember { mutableStateOf("") }
var afterInput by remember { mutableStateOf("") }
var beforeInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
Text(
text =
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
"The agent attaches the signed tag below to its events.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = agentInput,
onValueChange = {
agentInput = it
error = null
result = null
},
label = { Text("Agent public key (npub or hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = kindInput,
onValueChange = {
kindInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Restrict to kind (0–65535)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = afterInput,
onValueChange = {
afterInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events after (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = beforeInput,
onValueChange = {
beforeInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events before (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
is AttestationOutcome.Failure -> {
error = outcome.message
result = null
}
is AttestationOutcome.Success -> {
error = null
result = outcome.attestation
}
}
},
enabled = agentInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Generate attestation")
}
result?.let { attestation ->
AttestationResultCard(
attestation = attestation,
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
)
}
}
@Composable
private fun AttestationResultCard(
attestation: OwnerAttestation,
onCopy: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Signed attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"Grants: " +
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = attestation.toTagJson(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onCopy) {
Text("Copy tag")
}
}
Text(
text =
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
"workspace member, the relay lets this agent post as a member under the " +
"conditions above.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
private sealed interface AttestationOutcome {
data class Success(
val attestation: OwnerAttestation,
) : AttestationOutcome
data class Failure(
val message: String,
) : AttestationOutcome
}
/**
* Validates the form inputs and signs an [OwnerAttestation], or returns a
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
* plain function.
*/
private fun buildAttestation(
agentInput: String,
kindInput: String,
afterInput: String,
beforeInput: String,
ownerKey: KeyPair,
): AttestationOutcome {
val agentHex =
decodePublicKeyAsHexOrNull(agentInput.trim())
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
if (!agentHex.isValid()) {
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
}
val kind =
if (kindInput.isBlank()) {
null
} else {
val parsed = kindInput.toIntOrNull()
if (parsed == null || parsed !in 0..65535) {
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
}
parsed
}
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 0–4294967295.")
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 0–4294967295.")
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
return try {
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
} catch (e: IllegalArgumentException) {
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
}
}
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
private class OptionalUnix(
val value: Long?,
)
private fun parseOptionalUnix(input: String): OptionalUnix? {
if (input.isBlank()) return OptionalUnix(null)
val parsed = input.toLongOrNull() ?: return null
if (parsed !in 0..4294967295L) return null
return OptionalUnix(parsed)
}
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
@@ -0,0 +1,414 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.clickable
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.fillMaxSize
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.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary
import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
* that publish to them. Three tabs:
* - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend),
* derived from decrypted NIP-AM turn metrics (`kind:44200`).
* - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`).
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
*
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
* follow-up.
*/
@Composable
fun AgentConsoleScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$relayUrl-$pubkey")
viewModel.bind(accountViewModel.account, relayUrl)
// Keep Costs + Personas live while the console is open, so they populate once the relay's NIP-42
// auth completes (the one-shot fetch on bind can return before the authenticated read delivers).
DisposableEffect(relayUrl) {
viewModel.startWatching()
onDispose { viewModel.stopWatching() }
}
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
val personas by viewModel.personas.collectAsStateWithLifecycle()
val observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
val tabs = remember { listOf("Costs", "Personas", "Observer") }
// The observer stream is a live REQ; only keep it open while its tab is on screen.
if (selectedTab == 2) {
DisposableEffect(Unit) {
viewModel.startObserving()
onDispose { viewModel.stopObserving() }
}
}
Scaffold(
topBar = { TopBarWithBackButton("Agent Console", nav) },
floatingActionButton = {
FloatingActionButton(
onClick = { nav.nav(Route.AgentAttestation) },
shape = CircleShape,
) {
Icon(symbol = MaterialSymbols.Key, contentDescription = "Attest")
}
},
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> CostsTab(metrics)
1 -> PersonasTab(personas, nav)
else -> ObserverTab(observerFrames)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
)
}
}
}
}
}
@Composable
private fun CostsTab(metrics: AgentFleetMetrics) {
if (metrics.agents.isEmpty()) {
EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.")
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
FleetSummaryCard(metrics)
}
items(metrics.agents) { agent ->
AgentCard(agent)
}
}
}
@Composable
private fun FleetSummaryCard(metrics: AgentFleetMetrics) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Fleet total",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = formatCost(metrics.totals.costUsd),
style = MaterialTheme.typography.headlineMedium,
)
MetaRow("Agents", metrics.agents.size.toString())
MetaRow("Sessions", metrics.totalSessions.toString())
MetaRow("Turns", metrics.totalTurns.toString())
TokenBreakdown(metrics.totals)
if (metrics.hasUnreliableEstimates) {
Text(
text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun AgentCard(agent: AgentUsageSummary) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = shortKey(agent.agentPubKey),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = formatCost(agent.totals.costUsd),
style = MaterialTheme.typography.titleLarge,
)
MetaRow("Sessions", agent.sessions.toString())
MetaRow("Turns", agent.turns.toString())
if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", "))
if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", "))
agent.lastActivity?.let { MetaRow("Last turn", it) }
TokenBreakdown(agent.totals)
}
}
}
@Composable
private fun TokenBreakdown(totals: TokenTotals) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
MetaRow("Input tokens", formatCount(totals.inputTokens))
MetaRow("Output tokens", formatCount(totals.outputTokens))
MetaRow("Total tokens", formatCount(totals.totalTokens))
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
}
@Composable
private fun PersonasTab(
personas: List<AgentConsoleViewModel.PersonaCard>,
nav: INav,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
OutlinedButton(
onClick = { nav.nav(Route.AgentPersonaEdit()) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
Text(" New persona")
}
}
if (personas.isEmpty()) {
item {
Text(
text = "No personas published yet. Create one to define an agent (kind:30175).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(personas) { persona ->
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
}
}
}
@Composable
private fun PersonaCardView(
persona: AgentConsoleViewModel.PersonaCard,
onClick: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = persona.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = persona.slug,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
persona.model?.let { MetaRow("Model", it) }
persona.provider?.let { MetaRow("Provider", it) }
persona.runtime?.let { MetaRow("Runtime", it) }
persona.systemPrompt?.takeIf { it.isNotBlank() }?.let {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
if (frames.isEmpty()) {
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(frames) { frame ->
ObserverFrameRow(frame)
HorizontalDivider()
}
}
}
@Composable
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = frame.kind,
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = frame.timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val context =
buildString {
append(shortKey(frame.agentPubKey))
frame.sessionId?.let { append(" · session ${it.take(8)}") }
frame.turnId?.let { append(" · turn ${it.take(8)}") }
append(" · #${frame.seq}")
}
Text(
text = context,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun MetaRow(
label: String,
value: String,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun EmptyState(message: String) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) }
/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */
private fun padCents(value: String): String {
val dot = value.indexOf('.')
if (dot < 0) return "$value.00"
val decimals = value.length - dot - 1
return when {
decimals >= 2 -> value.substring(0, dot + 3)
decimals == 1 -> value + "0"
else -> value + "00"
}
}
/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */
private fun formatCount(n: Long): String {
val s = n.toString()
if (s.length <= 3) return s
val sb = StringBuilder()
val firstGroup = s.length % 3
if (firstGroup > 0) {
sb.append(s, 0, firstGroup)
}
var i = firstGroup
while (i < s.length) {
if (sb.isNotEmpty()) sb.append(' ')
sb.append(s, i, i + 3)
i += 3
}
return sb.toString()
}
private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "…" + hex.takeLast(8)
@@ -0,0 +1,345 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Collections
/**
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
* dashboard over their AI-agent fleet.
*
* It reads two Buzz kinds the owner authored or received:
* - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner.
* - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an
* agent published to the owner (`p` tag = owner). Either party can decrypt; here the
* owner's [Account.signer] does. Decryption is cached by event id so a refresh that
* re-reads the cache never re-runs NIP-44 on an event it already opened.
*
* [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set,
* lets [LocalCache] consume them, then re-derives the aggregate via the pure
* [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by
* the owner pubkey in the screen, so one instance survives navigation in and out.
*/
class AgentConsoleViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** The community (relay) this console is scoped to — agents/telemetry live on community relays. */
private var scopeRelay: NormalizedRelayUrl? = null
/** event id -> decrypted payload (null = decryption failed; don't retry). */
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
private val refreshMutex = Mutex()
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
private var observerJob: Job? = null
private var watchJob: Job? = null
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
/** Binds to [account] scoped to the community [relayUrl] — the console shows that community's fleet. */
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
this.scopeRelay = relay
this.account = account
relay?.let {
val newlyJoined = BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
refresh()
}
fun refresh() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
try {
// fetchFromRelays only primes LocalCache (no shared VM state); the serialized
// reloadFromCache below takes the mutex itself, so a concurrent watch reload
// can't race it on decryptCache / the derived StateFlows.
fetchFromRelays(account)
reloadFromCache(account)
} finally {
_isLoading.value = false
}
}
}
/**
* One-shot paged fetch of the owner's personas (authored by owner) and turn metrics
* (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the
* owner's own outbox relays. Events land in [LocalCache] via the normal consume path;
* this only primes the cache before [reloadFromCache] reads it back.
*/
private suspend fun fetchFromRelays(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = scopeRelay?.let { setOf(it) } ?: (BuzzRelayDialect.flow.value + account.outboxRelays.flow.value)
if (relays.isEmpty()) return
val filters =
listOf(
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
)
// The turn-metric read is `#p`-gated, so the Buzz relay requires NIP-42 auth — warm-auth
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
// Serialized: refresh() and every per-relay watch collector funnel through here, so the shared
// decryptCache (a plain HashMap) and the derived StateFlows are only ever mutated single-threaded.
private suspend fun reloadFromCache(account: Account) =
refreshMutex.withLock {
val myPubkey = account.userProfile().pubkeyHex
val signer = account.signer
_personas.value =
LocalCache.addressables
.filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent }
.mapNotNull { note ->
val event = note.event as? PersonaEvent ?: return@mapNotNull null
val content = event.personaOrNull()
PersonaCard(
slug = event.slug(),
displayName = content?.displayName ?: event.slug(),
model = content?.model,
runtime = content?.runtime,
provider = content?.provider,
systemPrompt = content?.systemPrompt,
)
}.sortedBy { it.displayName.lowercase() }
val metricNotes =
LocalCache.filter(
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
)
val decrypted =
metricNotes.mapNotNull { note ->
val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null
val payload =
if (decryptCache.containsKey(event.id)) {
decryptCache[event.id]
} else {
event.decryptOrNull(signer).also { decryptCache[event.id] = it }
} ?: return@mapNotNull null
val agent = event.agentPubKey() ?: event.pubKey
agent to payload
}
_metrics.value = AgentFleetAggregator.aggregate(decrypted)
}
/**
* Opens a live subscription to the owner's ephemeral observer telemetry frames
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
* to tear the subscription down. Observer frames are never stored by relays or
* [LocalCache], so this live REQ is the only way to see them.
*/
fun startObserving() {
val account = account ?: return
if (observerJob != null) return
val relays = scopeRelay?.let { setOf(it) } ?: BuzzRelayDialect.flow.value
if (relays.isEmpty()) return
val signer = account.signer
val myPubkey = account.userProfile().pubkeyHex
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
observerJob =
viewModelScope.launch(Dispatchers.IO) {
relays.forEach { relay ->
launch {
account.client.subscribeAsFlow(relay, filter).collect { events ->
// Observer frames are ephemeral and unbounded over a long session; keep the
// dedup set from growing without limit. We only render the newest ~200 rows,
// so dropping the seen-history occasionally risks at most a stale duplicate.
if (observerSeen.size > MAX_OBSERVER_ROWS * 4) observerSeen.clear()
val fresh =
events
.filterIsInstance<ObserverFrameEvent>()
.filter { observerSeen.add(it.id) }
if (fresh.isEmpty()) return@collect
val rows =
fresh.mapNotNull { frame ->
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
ObserverRow(
seq = payload.seq,
timestamp = payload.timestamp,
kind = payload.kind,
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
sessionId = payload.sessionId,
turnId = payload.turnId,
)
}
if (rows.isNotEmpty()) {
_observerFrames.update { existing ->
(rows + existing)
.sortedByDescending { it.timestamp }
.take(MAX_OBSERVER_ROWS)
}
}
}
}
}
}
}
fun stopObserving() {
observerJob?.cancel()
observerJob = null
}
/**
* Keeps the Costs + Personas tabs live while the console is open: a persistent subscription for
* the owner's turn metrics (44200, `#p` = me) and persona definitions (30175, authored by me) on
* this community's relay, re-deriving both tabs from [LocalCache] whenever a batch arrives.
*
* This is what makes the console populate right after the NIP-42 auth handshake completes: the
* one-shot [refresh] fetch can return before the authenticated retry actually delivers, so
* without a live watch the tabs stay empty until a manual refresh. Idempotent; [stopWatching]
* tears it down when the screen leaves.
*/
fun startWatching() {
val account = account ?: return
if (watchJob != null) return
val relays = scopeRelay?.let { setOf(it) } ?: (BuzzRelayDialect.flow.value + account.outboxRelays.flow.value)
if (relays.isEmpty()) return
val myPubkey = account.userProfile().pubkeyHex
val filters =
listOf(
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
)
watchJob =
viewModelScope.launch(Dispatchers.IO) {
relays.forEach { relay ->
launch {
account.client.subscribeAsFlow(relay, filters).collect {
// The client's global listener has already consumed the batch into LocalCache;
// re-derive both tabs from it. reloadFromCache is idempotent + decrypt-cached.
reloadFromCache(account)
}
}
}
}
}
fun stopWatching() {
watchJob?.cancel()
watchJob = null
}
override fun onCleared() {
stopObserving()
stopWatching()
super.onCleared()
}
/** One decrypted observer telemetry frame rendered on the Observer tab. */
@Immutable
data class ObserverRow(
val seq: Long,
val timestamp: String,
val kind: String,
val agentPubKey: HexKey,
val sessionId: String?,
val turnId: String?,
)
/** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */
@Immutable
data class PersonaCard(
val slug: String,
val displayName: String,
val model: String?,
val runtime: String?,
val provider: String?,
val systemPrompt: String?,
)
companion object {
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
const val MAX_OBSERVER_ROWS = 200
}
}
@@ -0,0 +1,145 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
* in-progress edit survives recomposition.
*/
@Composable
fun AgentPersonaEditScreen(
slug: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
viewModel.bind(accountViewModel.account, slug)
val state by viewModel.state.collectAsStateWithLifecycle()
val title = if (slug == null) "New persona" else "Edit persona"
Scaffold(
topBar = { TopBarWithBackButton(title, nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = state.slug,
onValueChange = viewModel::onSlugChange,
label = { Text("Slug (persona id)") },
singleLine = true,
enabled = !state.slugLocked,
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.displayName,
onValueChange = viewModel::onDisplayNameChange,
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.systemPrompt,
onValueChange = viewModel::onSystemPromptChange,
label = { Text("System prompt") },
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.model,
onValueChange = viewModel::onModelChange,
label = { Text("Model (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.provider,
onValueChange = viewModel::onProviderChange,
label = { Text("Provider (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.runtime,
onValueChange = viewModel::onRuntimeChange,
label = { Text("Runtime (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.avatarUrl,
onValueChange = viewModel::onAvatarUrlChange,
label = { Text("Avatar URL (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { viewModel.save(onDone = nav::popBack) },
enabled = state.canSave,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.isSaving) "Publishing…" else "Publish persona")
}
}
}
}
@@ -0,0 +1,174 @@
/*
* 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.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
* owner and addressed by `(owner, 30175, slug)`.
*
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
* different slug is a different persona.
*
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
* none are known), so the workspace and other members see it.
*/
class AgentPersonaEditViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
private var existing: PersonaContent? = null
private var editingSlug: String? = null
private val _state = MutableStateFlow(FormState())
val state: StateFlow<FormState> = _state.asStateFlow()
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
fun bind(
account: Account,
slug: String?,
) {
if (this.account != null) return
this.account = account
if (slug.isNullOrBlank()) return
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
val event = note?.event as? PersonaEvent ?: return
val content = event.personaOrNull() ?: return
existing = content
editingSlug = slug
_state.value =
FormState(
slug = slug,
slugLocked = true,
displayName = content.displayName,
systemPrompt = content.systemPrompt.orEmpty(),
model = content.model.orEmpty(),
runtime = content.runtime.orEmpty(),
provider = content.provider.orEmpty(),
avatarUrl = content.avatarUrl.orEmpty(),
)
}
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
/**
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
*/
fun save(onDone: () -> Unit) {
val account = account ?: return
val current = _state.value
val slug = current.slug.trim()
if (!isValidSlug(slug)) {
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
return
}
if (current.displayName.isBlank()) {
_state.update { it.copy(error = "Display name is required.") }
return
}
_state.update { it.copy(isSaving = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
try {
val base = existing ?: PersonaContent(displayName = current.displayName)
val content =
base.copy(
displayName = current.displayName.trim(),
systemPrompt = current.systemPrompt.blankToNull(),
model = current.model.blankToNull(),
runtime = current.runtime.blankToNull(),
provider = current.provider.blankToNull(),
avatarUrl = current.avatarUrl.blankToNull(),
)
val template = PersonaEvent.build(content, slug)
account.signAndSendPrivatelyOrBroadcast(template) {
BuzzRelayDialect.flow.value
.toList()
.ifEmpty { null }
}
viewModelScope.launch(Dispatchers.Main) { onDone() }
} catch (e: Exception) {
if (e is CancellationException) throw e
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
}
}
}
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
data class FormState(
val slug: String = "",
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
val slugLocked: Boolean = false,
val displayName: String = "",
val systemPrompt: String = "",
val model: String = "",
val runtime: String = "",
val provider: String = "",
val avatarUrl: String = "",
val isSaving: Boolean = false,
val error: String? = null,
) {
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
}
companion object {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
}
}
@@ -0,0 +1,163 @@
/*
* 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.screen.loggedIn.buzz
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* A reusable "add a person" dialog: a typeahead over the local user cache (name / NIP-05 / npub
* prefix, or a pasted npub/hex). Tapping a result that isn't already in the target invokes [onAdd];
* members already present are shown with an "Added" hint and aren't tappable.
*
* Context-agnostic — the caller supplies [isAlreadyIn] (membership predicate) and [onAdd] (the
* actual add, e.g. a channel kind-9000 put-user or a community kind-9030 admin-add). Used by both
* the channel members screen and the Buzz community view.
*/
@Composable
fun BuzzAddPeopleDialog(
title: String,
accountViewModel: AccountViewModel,
nav: INav,
isAlreadyIn: (HexKey) -> Boolean,
onAdd: (HexKey) -> Unit,
onDismiss: () -> Unit,
) {
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<HexKey>>(emptyList()) }
LaunchedEffect(query) {
if (query.isBlank()) {
results = emptyList()
return@LaunchedEffect
}
delay(150)
results =
withContext(Dispatchers.IO) {
LocalCache
.findUsersStartingWith(query.trim(), accountViewModel.account)
.map { it.pubkeyHex }
.take(15)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
)
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
items(results, key = { it }) { hex ->
val alreadyIn = isAlreadyIn(hex)
AddPersonRow(hex, alreadyIn, accountViewModel, nav) {
if (!alreadyIn) {
onAdd(hex)
onDismiss()
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
)
}
@Composable
private fun AddPersonRow(
hex: HexKey,
alreadyIn: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !alreadyIn, onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(hex, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
if (user != null) {
UsernameDisplay(user, accountViewModel = accountViewModel)
} else {
Text(hex.take(8), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (alreadyIn) {
Text(
text = stringRes(R.string.buzz_import_added),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,232 @@
/*
* 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.screen.loggedIn.buzz
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown document for a channel.
* The canvas is overlay state held in [BuzzWorkspaceStates] (never a timeline row — a workspace has
* one live canvas, last-write-wins), so this reads the registry by the channel's `h` id and
* re-composes off `canvasUpdates` when a newer revision lands.
*
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
* channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new
* revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh.
*/
@Composable
fun BuzzCanvasScreen(
channelId: String,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Re-read canvasNote whenever a newer canvas is consumed.
val version by state.canvasUpdates.collectAsStateWithLifecycle()
val canvas = remember(version) { state.canvasNote }
val content = canvas?.event?.content
var editing by remember { mutableStateOf(false) }
if (editing) {
CanvasEditor(
channelId = channelId,
relayUrl = relayUrl,
initial = content.orEmpty(),
accountViewModel = accountViewModel,
onClose = { editing = false },
)
return
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
floatingActionButton = {
FloatingActionButton(onClick = { editing = true }) {
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit))
}
},
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
Box(
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.buzz_canvas_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@Scaffold
}
val bgColor = remember { mutableStateOf(Color.Transparent) }
Box(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TranslatableRichTextViewer(
content = content,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = bgColor,
id = canvas.idHex,
callbackUri = canvas.toNostrUri(),
authorPubKey = canvas.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CanvasEditor(
channelId: String,
relayUrl: String,
initial: String,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
) {
var text by remember { mutableStateOf(initial) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// Back cancels the edit and returns to the rendered canvas rather than leaving the screen.
BackHandler(enabled = !saving, onBack = onClose)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.buzz_canvas_edit)) },
navigationIcon = {
IconButton(onClick = onClose, enabled = !saving) {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.cancel))
}
},
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
val relay =
RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: run {
error = "Invalid relay url"
return@FloatingActionButton
}
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) {
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
CanvasEvent.build(channelId, text),
) { listOf(relay) }
}
onClose()
} catch (e: Exception) {
saving = false
error = "Failed to save: ${e.message ?: e::class.simpleName}"
}
}
},
) {
if (saving) {
CircularProgressIndicator(modifier = Modifier.padding(14.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Check, contentDescription = stringRes(R.string.buzz_canvas_save))
}
}
},
) { padding ->
Box(Modifier.padding(padding).fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = null
},
modifier = Modifier.fillMaxSize(),
enabled = !saving,
label = { Text(stringRes(R.string.buzz_canvas_body_label)) },
)
error?.let {
SelectionContainer(Modifier.align(Alignment.BottomStart)) {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@@ -0,0 +1,131 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* Always-on discovery of the viewer's Buzz **DM channels** across every joined workspace relay, mounted
* once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage]).
*
* The deployed relay does not expose a queryable DM list; it addresses each member a kind-44100
* member-added notification (`#p` = me). This warm-auths a `#p=me` fetch of 44100 (+ the 30622 visibility
* snapshot) across the joined relays, records the channels into [BuzzDmChannels], fetches each channel's
* 39000-39003 directory (so its `t`=dm marker + participants land in `LocalCache`), and keeps a live
* `#p=me` 44100 subscription open for new DMs. The companion [BuzzDmJoinedChatTailPreload] then keeps the
* discovered channels' messages warm app-wide — which is what lets a Buzz DM show on the Notifications tab
* and in push without the viewer opening the conversation first.
*
* This mirrors [BuzzDmListViewModel]'s discovery, but account-scoped and always-on rather than bound to
* the open inbox screen; the inbox keeps its own scoped copy for its per-relay projection.
*/
@Composable
fun BuzzDmDiscoveryPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val joined by BuzzWorkspaces.flow.collectAsStateWithLifecycle()
// Restart the whole discovery (initial warm-auth fetch + live 44100 subs) whenever the joined
// workspace set changes; the LaunchedEffect scope owns the live subscriptions and cancels them on
// account switch or dispose.
LaunchedEffect(account, joined) {
if (joined.isEmpty()) return@LaunchedEffect
runBuzzDmDiscovery(account, joined)
}
}
/**
* Warm-auth the initial 44100/30622 `#p=me` read across [relays], record every discovered channel, fetch
* their directories, then keep a live 44100 subscription per relay open until the caller's scope is
* cancelled. Suspends for the lifetime of the live subscriptions.
*/
private suspend fun runBuzzDmDiscovery(
account: Account,
relays: Set<NormalizedRelayUrl>,
) = coroutineScope {
val me = account.userProfile().pubkeyHex
// A joined workspace is first-party: pre-approve NIP-42 so the `#p=me` DM reads authenticate (the
// restore-from-disk path doesn't set this, unlike the inbox/import/console entry points).
relays.forEach { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
val discoveryFilters =
listOf(
Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me))),
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(me))),
)
// `#p`-gated reads: use the warm-auth fetch so an `auth-required` CLOSED authenticates and retries
// rather than returning empty.
account.client.fetchAllWithHooks(
filters = relays.associateWith { discoveryFilters },
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { BuzzDmChannels.record(me, it, relay) }
false
}
fetchDmMetadata(account, me)
relays.forEach { relay ->
launch {
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
e.channel()?.let { if (BuzzDmChannels.record(me, it, relay)) changed = true }
}
if (changed) fetchDmMetadata(account, me)
}
}
}
}
/** Fetch the NIP-29 directory (39000-39003) of every known DM channel so its `t`=dm marker + roster load. */
private suspend fun fetchDmMetadata(
account: Account,
viewer: HexKey,
) {
val byRelay =
BuzzDmChannels
.channelsFor(viewer)
.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
@@ -0,0 +1,395 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.launch
/**
* The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group
* whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen
* every workspace channel uses — this screen only lists the conversations (discovered from
* the relay's kind-41001 confirmations via [BuzzDmListViewModel]) and starts new ones.
*/
@Composable
fun BuzzDmListScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: BuzzDmListViewModel = viewModel(key = "BuzzDmList-$relayUrl-$pubkey")
viewModel.bind(accountViewModel.account, relayUrl)
val rows by viewModel.rows.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) },
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringRes(R.string.buzz_dm_new)) },
icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) },
onClick = { nav.nav(Route.BuzzNewDm(relayUrl)) },
)
},
) { padding ->
if (rows.isEmpty()) {
EmptyDmInbox(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(rows, key = { it.channelId }) { row ->
DmRowCard(row, accountViewModel, nav)
}
}
}
}
}
/** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */
@Composable
private fun DmRowCard(
row: BuzzDmListViewModel.DmRow,
accountViewModel: AccountViewModel,
nav: INav,
) {
val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) }
var menuOpen by remember { mutableStateOf(false) }
var addMemberOpen by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (addMemberOpen) {
AddDmMemberDialog(
onDismiss = { addMemberOpen = false },
onAdd = { hex ->
addMemberOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.addBuzzDmMember(channel, hex)
}
},
)
}
Card(
onClick = { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DmAvatars(row.others, accountViewModel, nav)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
DmTitle(row.others, accountViewModel)
Text(
text = row.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Text(
text = timeAgoShort(row.lastActivity, stringRes(R.string.now)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.buzz_dm_more),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clip(CircleShape)
.clickable { menuOpen = true }
.size(28.dp)
.padding(4.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_add_member)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
addMemberOpen = true
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_hide)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.hideBuzzDm(channel)
}
},
)
}
}
}
}
}
/** The DM title: the other participants' display names, comma-joined (or a "+N" tail). */
@Composable
private fun DmTitle(
others: List<HexKey>,
accountViewModel: AccountViewModel,
) {
// `map` is inline (a @Composable call is legal inside it); resolve every shown name
// first, then join the plain strings — joinToString is NOT inline so can't call one.
val text =
if (others.isEmpty()) {
stringRes(R.string.buzz_dm_just_you)
} else {
val shown = others.take(2).map { UserName(it, accountViewModel) }
val joined = shown.joinToString(", ")
if (others.size > 2) "$joined +${others.size - 2}" else joined
}
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/** A resolved display name for [userHex] (falls back to a short pubkey while loading). */
@Composable
private fun UserName(
userHex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user: User = remember(userHex) { LocalCache.getOrCreateUser(userHex) }
val name by observeUserName(user, accountViewModel)
return name
}
/** A single avatar for a 1:1 DM, or two overlapping avatars for a group DM. */
@Composable
private fun DmAvatars(
others: List<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
) {
when {
others.isEmpty() ->
Box(
modifier = Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
others.size == 1 -> UserPicture(others[0], 44.dp, accountViewModel = accountViewModel, nav = nav)
else ->
Box(modifier = Modifier.size(44.dp)) {
UserPicture(
others[1],
30.dp,
pictureModifier = Modifier.align(Alignment.BottomEnd),
accountViewModel = accountViewModel,
nav = nav,
)
UserPicture(
others[0],
30.dp,
pictureModifier =
Modifier
.align(Alignment.TopStart)
.offset(x = (-2).dp, y = (-2).dp),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/**
* Dialog to add another person to an existing group DM: accepts an npub or 64-char hex key,
* validates it locally, and hands the resolved hex to [onAdd] (which publishes kind-41011).
*/
@Composable
private fun AddDmMemberDialog(
onDismiss: () -> Unit,
onAdd: (HexKey) -> Unit,
) {
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val invalidMsg = stringRes(R.string.buzz_dm_add_member_invalid)
fun submit() {
val hex = decodePublicKeyAsHexOrNull(input.trim())?.takeIf { it.isValid() }
if (hex == null) {
error = invalidMsg
} else {
onAdd(hex)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.buzz_dm_add_member_title)) },
text = {
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
)
},
confirmButton = {
TextButton(onClick = { submit() }, enabled = input.isNotBlank()) {
Text(stringRes(R.string.buzz_dm_add_member))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
/** Inviting empty state for a fresh DM inbox. */
@Composable
private fun EmptyDmInbox(modifier: Modifier = Modifier) {
Card(
modifier = modifier.padding(24.dp).fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp),
)
}
Text(
text = stringRes(R.string.buzz_dm_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_dm_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,259 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* Backing ViewModel for [BuzzDmListScreen] — the user's Buzz direct-message inbox.
*
* A Buzz DM is a relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID, so the
* message timeline reuses the whole relay-group chat stack; this ViewModel owns only *discovery*
* and the inbox projection. Discovery mirrors how the deployed relay actually models DMs (it does
* NOT emit a queryable kind-41001): the relay addresses each member a kind-44100 member-added
* notification (`#p` = me, `h` = channel), and marks a channel a DM via the `t` tag on its
* kind-39000 metadata (with the participants inlined as `p` tags). So it:
* - fetches + live-subscribes 44100 (`#p` = me) across the joined Buzz relays to learn the
* channels the user is in;
* - fetches each channel's directory (39000-39003) and keeps the ones whose metadata says
* `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate
* needs, and the DM participants;
* - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in
* [BuzzDmRegistry]) drops out;
* - projects the visible DMs into [rows], sorted by last message time.
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** The community (relay) this inbox is scoped to — DMs are per-community. */
private var scopeRelay: NormalizedRelayUrl? = null
private val refreshMutex = Mutex()
private var liveJob: Job? = null
/** channelId -> relay it was discovered on (from the 44100 provenance). */
private val memberChannels = ConcurrentHashMap<String, NormalizedRelayUrl>()
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
/** One inbox row: a materialized DM plus what the UI needs to render it. */
@Immutable
data class DmRow(
val channelId: String,
val relayUrl: NormalizedRelayUrl,
/** All participants (from the 39000 metadata `p` tags), including me. */
val allParticipants: List<HexKey>,
/** Participants other than me — who the DM is "with". */
val others: List<HexKey>,
/** Newest message time (or 0 when the DM has no messages yet). */
val lastActivity: Long,
)
private fun relays(): Set<NormalizedRelayUrl> = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
/**
* Binds to [account] scoped to the community [relayUrl]. Marks that relay a joined workspace and
* pre-approves NIP-42 so the read-only `#p=me` DM discovery authenticates against it.
*/
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
this.account = account
// A malformed relay URL must not silently fall back to querying every joined workspace
// (relays() does that when scopeRelay is null) — this screen is scoped to one community.
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return
this.scopeRelay = relay
val newlyJoined = BuzzWorkspaces.join(relay)
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
refresh()
startLive()
}
fun refresh() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
_isLoading.value = true
try {
discoverMemberChannels(account)
fetchMetadata(account)
rebuildRows(account)
} finally {
_isLoading.value = false
}
}
}
}
/**
* Fetch kind-44100 (`#p` = me) + the visibility snapshot (30622) across the joined relays. These
* reads are `#p`-gated so the Buzz relay requires NIP-42 auth — use the warm-auth fetch
* (`pendingOnAuthRequired`) so it authenticates on the `auth-required` CLOSED and retries, rather
* than returning empty (this is why the import lists channels but a plain fetch wouldn't).
*/
private suspend fun discoverMemberChannels(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = relays()
if (relays.isEmpty()) return
val filters =
listOf(
Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
)
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
false
}
}
/** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t`/roster load. */
private suspend fun fetchMetadata(account: Account) {
val byRelay =
memberChannels.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
private fun rebuildRows(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
_rows.value =
memberChannels.entries
.mapNotNull { (channelId, relay) ->
if (channelId in hidden) return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
val metadata = channel.event ?: return@mapNotNull null
if (!metadata.isBuzzDm()) return@mapNotNull null
val participants = metadata.buzzParticipants()
DmRow(
channelId = channelId,
relayUrl = relay,
allParticipants = participants,
others = participants.filter { it != myPubkey },
lastActivity = lastActivityFor(channelId),
)
}.sortedByDescending { it.lastActivity }
}
/** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */
private fun lastActivityFor(channelId: String): Long =
LocalCache
.filter(
Filter(
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND),
tags = mapOf("h" to listOf(channelId)),
),
).maxOfOrNull { it.createdAt() ?: 0L } ?: 0L
/**
* Keeps a live 44100 + 30622 REQ open (so new DMs / hide changes arrive) and re-projects the
* inbox when the registry or dialect set moves. Idempotent; torn down with the ViewModel.
*/
private fun startLive() {
val account = account ?: return
if (liveJob != null) return
val myPubkey = account.userProfile().pubkeyHex
liveJob =
viewModelScope.launch(Dispatchers.IO) {
relays().forEach { relay ->
launch {
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true }
}
if (changed) {
fetchMetadata(account)
rebuildRows(account)
}
}
}
launch {
val filter = Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { /* consumed → BuzzDmRegistry.hidden */ }
}
}
// Re-project when my hidden set (30622) or the joined-relay set changes.
launch {
combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
.collect { rebuildRows(account) }
}
}
}
override fun onCleared() {
liveJob?.cancel()
liveJob = null
super.onCleared()
}
}
@@ -0,0 +1,142 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Composer for a Buzz **forum post** (NIP kind 45001) — the root of a forum thread in a
* workspace channel. The post body is the event content (Buzz forum posts carry no
* separate title). Published to the channel's host relay via `build_forum_post`'s Quartz
* equivalent ([ForumPostEvent.build]). Reached from the Threads tab's compose FAB on a
* Buzz-dialect relay.
*/
@Composable
fun BuzzForumPostScreen(
channelId: String,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
var body by remember { mutableStateOf("") }
var isPosting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_forum_new_title), nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = body,
onValueChange = {
body = it
error = null
},
label = { Text(stringRes(R.string.buzz_forum_body_label)) },
minLines = 5,
modifier = Modifier.fillMaxWidth(),
enabled = !isPosting,
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
if (relay == null) {
error = "Invalid relay url"
return@Button
}
isPosting = true
error = null
// Publish on IO, then pop back on the composition's main scope.
scope.launch {
try {
withContext(Dispatchers.IO) {
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
ForumPostEvent.build(channelId, body.trim()),
) { listOf(relay) }
}
nav.popBack()
} catch (e: Exception) {
isPosting = false
error = "Failed to post: ${e.message ?: e::class.simpleName}"
}
}
},
enabled = body.isNotBlank() && !isPosting,
modifier = Modifier.fillMaxWidth(),
) {
if (isPosting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(stringRes(R.string.buzz_forum_post_action))
}
}
}
}
}
@@ -0,0 +1,226 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
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.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.delay
/**
* The detail view of a Buzz **forum thread**: the root post (kind 45001) and its comment
* replies (kind 45003). Replies compose through the same rich chat composer everything else
* uses ([EditFieldRow] + [ForumReplyNewMessageViewModel]), which just swaps the built event for
* a kind-45003 [ForumCommentEvent]. Kept apart from the generic note thread screen because Buzz
* forum comments use NIP-10 `e` tags (not the kind-1111 NIP-22 shape), so they need their own
* root-scoped fetch.
*/
@Composable
fun BuzzForumThreadScreen(
channelId: String,
relayUrl: String,
rootId: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) }
val channel = remember(channelId, relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay)) }
// The replies to this root. Seed from cache (the forum-list REQ already pulls 45003 for the
// channel), then live-subscribe by the root `e` tag so replies arrive while the thread is open.
val replies = remember(rootId) { mutableStateListOf<ForumCommentEvent>() }
val seen = remember(rootId) { HashSet<HexKey>() }
val replyFilter =
remember(rootId) {
Filter(kinds = listOf(ForumCommentEvent.KIND), tags = mapOf("e" to listOf(rootId)))
}
fun accept(event: ForumCommentEvent) {
if (event.threadRoot() != rootId && event.replyTo() != rootId) return
if (!seen.add(event.id)) return
val at = replies.indexOfFirst { it.createdAt > event.createdAt }
if (at < 0) replies.add(event) else replies.add(at, event)
}
// Bumped after we post a reply. The relay stores our reply but doesn't re-deliver it on our already-open
// subscription, so a bump restarts the REQ — a fresh fetch returns every reply, including the new one.
var refreshKey by remember(rootId) { mutableIntStateOf(0) }
LaunchedEffect(rootId, refreshKey) {
if (refreshKey > 0) delay(600) // let our just-sent reply persist on the relay before re-fetching
LocalCache.filter(replyFilter).forEach { (it.event as? ForumCommentEvent)?.let(::accept) }
accountViewModel.account.client.subscribeAsFlow(relay, replyFilter).collect { events ->
events.filterIsInstance<ForumCommentEvent>().forEach(::accept)
}
}
val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex)
val replyModel: ForumReplyNewMessageViewModel = viewModel(key = "forum-reply-$rootId")
replyModel.init(accountViewModel)
replyModel.loadForumThread(channel, rootId)
// DisappearingScaffold (not a plain Scaffold) so the composer + list get the app's shared
// imePadding + navigation-bar insets, matching RelayGroupChatScreen. The composer is content at
// the bottom of the Column (like the chat), not a Scaffold bottomBar.
DisappearingScaffold(
isInvertedLayout = false,
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_forum_thread_title), nav) },
accountViewModel = accountViewModel,
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
LazyColumn(modifier = Modifier.weight(1f)) {
item(key = "root") {
ForumPostBody(rootNote.event as? ForumPostEvent, rootNote.author?.pubkeyHex ?: rootId, accountViewModel, nav)
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
item(key = "reply-count") {
RepliesCountHeader(replies.size)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
if (replies.isEmpty()) {
item(key = "no-replies") { NoRepliesYet() }
} else {
items(replies, key = { it.id }) { reply ->
ForumReplyRow(reply, accountViewModel, nav)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
if (canPost) {
// Reuse the full chat composer; a send publishes the kind-45003 reply and then bumps
// refreshKey to re-fetch (the relay doesn't echo our reply on the open subscription).
EditFieldRow(replyModel, accountViewModel, onSendNewMessage = { refreshKey++ }, nav)
}
}
}
}
@Composable
private fun ForumPostBody(
event: ForumPostEvent?,
authorPubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val author = remember(authorPubkey) { LocalCache.getOrCreateUser(authorPubkey) }
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
UsernameDisplay(author, accountViewModel = accountViewModel, fontWeight = FontWeight.SemiBold)
}
Text(text = event?.body().orEmpty(), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun RepliesCountHeader(count: Int) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = MaterialSymbols.Forum,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun NoRepliesYet() {
Text(
text = stringRes(R.string.buzz_forum_no_replies),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 24.dp),
)
}
@Composable
private fun ForumReplyRow(
reply: ForumCommentEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val author = remember(reply.pubKey) { LocalCache.getOrCreateUser(reply.pubKey) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
UsernameDisplay(author, accountViewModel = accountViewModel)
Text(text = reply.body(), style = MaterialTheme.typography.bodyMedium)
}
}
}
@@ -0,0 +1,201 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.graphics.Color
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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* One row in the "your channels on this workspace" section: a Buzz workspace channel the user is a
* member of (via kind-44100). Tapping the card opens the channel ([onOpen]); the trailing Add
* affordance appends it to the kind-10009 list so it surfaces in Messages / Relay Groups. Reused by
* the relay group-list screen where Buzz membership discovery is folded in.
*/
@Composable
fun BuzzImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
// An unread dot when this group has chat newer than the last time this account opened it.
val hasUnread by remember(groupId) {
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
}.collectAsStateWithLifecycle(false)
val content =
@Composable {
BuzzImportRowContent(name, groupId.id, memberCount, isAdded, hasUnread, isStarred, onToggleStar, onAdd)
}
if (onOpen != null) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() }
} else {
Card(modifier = Modifier.fillMaxWidth()) { content() }
}
}
@Composable
private fun BuzzImportRowContent(
name: String,
seed: String,
memberCount: Int,
isAdded: Boolean,
hasUnread: Boolean,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
BuzzImportAvatar(name = name, seed = seed)
if (hasUnread) {
Box(
Modifier
.align(Alignment.TopEnd)
.size(11.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (memberCount > 0) {
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (onToggleStar != null) {
IconButton(onClick = onToggleStar) {
Icon(
symbol = if (isStarred) MaterialSymbols.Star else MaterialSymbols.StarBorder,
contentDescription = stringRes(if (isStarred) R.string.buzz_unstar else R.string.buzz_star),
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (isAdded) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = stringRes(R.string.buzz_import_added),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
} else {
OutlinedButton(onClick = onAdd) {
Text(stringRes(R.string.buzz_import_add))
}
}
}
}
/** A round monogram whose color is derived deterministically from the channel id. */
@Composable
private fun BuzzImportAvatar(
name: String,
seed: String,
) {
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
val initial =
remember(name) {
name
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString() ?: "#"
}
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White)
}
}
@@ -0,0 +1,230 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
/**
* Landing screen for a Buzz workspace invite link (`https://<host>/invite/<token>`), reached
* from the deep-link / tapped-link / search interceptors instead of the external browser.
*
* A Buzz invite is redeemed over HTTP (accept the join policy, then a NIP-98-signed claim) —
* a flow the Buzz web app already implements and drives through `window.nostr`. So rather than
* re-implement the legally-sensitive age/privacy consent natively, this confirms the workspace,
* marks its relay as a Buzz dialect, and hands the URL to the in-app `window.nostr` browser
* ([FavoriteAppLauncher.launchUrl] → the sandboxed WebView), where the SPA signs the claim with
* the user's key. The user returns here (or to the app) once enrolled.
*/
@Composable
fun BuzzInviteScreen(
link: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val invite = remember(link) { BuzzInviteLink.parse(link) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var launched by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_invite_title), nav) },
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (invite == null) {
Text(stringRes(R.string.buzz_invite_invalid), style = MaterialTheme.typography.bodyLarge)
return@Column
}
val expired = remember(invite) { invite.isExpired(TimeUtils.now()) }
Spacer(Modifier.size(8.dp))
Box(
modifier =
Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(36.dp),
)
}
Text(
text = stringRes(R.string.buzz_invite_heading),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InviteRow(stringRes(R.string.buzz_invite_workspace), invite.host)
InviteRow(stringRes(R.string.buzz_invite_role), invite.role)
}
}
Text(
text = stringRes(R.string.buzz_invite_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (expired) {
Text(
text = stringRes(R.string.buzz_invite_expired),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.weight(1f))
if (launched) {
// After the browser hand-off: gently point back to the workspace, which now
// authenticates + discovers its channels (the claim granted membership).
Text(
text = stringRes(R.string.buzz_invite_after_browser),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.size(4.dp))
Button(
onClick = {
// Open the relay's group screen, which now lists the channels the claim just
// granted you membership to (importable into Amethyst).
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
nav.newStack(Route.RelayGroupServer(relay.url))
}
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_open_workspace))
}
} else {
Button(
onClick = {
// Remember the workspace as joined (persisted; marks it a Buzz dialect) and
// pre-approve NIP-42 auth to its relay so the read-only channel/DM discovery
// authenticates without a prompt, then hand off to the in-app window.nostr
// browser to accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
BuzzWorkspaces.join(relay)
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
}
FavoriteAppLauncher.launchUrl(context, link)
launched = true
},
enabled = !expired,
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_continue))
}
}
}
}
}
@Composable
private fun InviteRow(
label: String,
value: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(96.dp),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,253 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Start a new Buzz DM: pick a workspace relay, add 1-8 people (search by name — this workspace's
* members rank first — or paste an npub/hex), and open. On the relay's confirmation the screen jumps
* straight into the shared [Route.RelayGroup] chat for the new conversation; on timeout it falls
* back to the DM inbox.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BuzzNewDmScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: BuzzNewDmViewModel = viewModel(key = "BuzzNewDm-$relayUrl")
viewModel.bind(accountViewModel.account, relayUrl)
val relays by viewModel.relays.collectAsStateWithLifecycle()
val selectedRelay by viewModel.relay.collectAsStateWithLifecycle()
val participants by viewModel.participants.collectAsStateWithLifecycle()
val status by viewModel.status.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
var inputError by remember { mutableStateOf<String?>(null) }
val sending = status is BuzzNewDmViewModel.Status.Sending
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_new), nav) },
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Workspace relay — a DM lives on exactly one Buzz relay (its tenant).
if (relays.isNotEmpty()) {
Text(
text = stringRes(R.string.buzz_dm_workspace),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
relays.forEach { relay ->
FilterChip(
selected = relay == selectedRelay,
onClick = { viewModel.selectRelay(relay) },
label = { Text(relay.displayUrl(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
}
// Recipients
Text(
text = stringRes(R.string.buzz_dm_recipients),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
if (participants.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
participants.forEach { hex ->
ParticipantChip(hex, accountViewModel, nav) { viewModel.removeParticipant(hex) }
}
}
}
OutlinedTextField(
value = query,
onValueChange = {
viewModel.updateQuery(it)
inputError = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
singleLine = true,
isError = inputError != null,
supportingText = inputError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
// Enter accepts a pasted npub/hex directly — the escape hatch for someone
// not yet in the local cache, so they'd never surface in the search list.
onDone = {
if (query.isNotBlank()) {
inputError = viewModel.addRawKey(query)
}
},
),
)
(status as? BuzzNewDmViewModel.Status.Error)?.let {
Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
// Search results — workspace members first — fill the space above the pinned Start button.
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
items(suggestions, key = { it }) { hex ->
// Surface the same errors the Enter/paste path shows (e.g. the participant ceiling),
// instead of a tap that silently does nothing.
SuggestionRow(hex, accountViewModel, nav) { inputError = viewModel.addParticipant(hex) }
}
}
Button(
onClick = {
viewModel.start { groupId ->
if (groupId != null) {
nav.newStack(Route.RelayGroup(groupId.id, groupId.relayUrl.url))
} else {
nav.newStack(Route.BuzzDmList(relayUrl))
}
}
},
enabled = !sending && participants.isNotEmpty() && selectedRelay != null,
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_dm_opening))
} else {
Icon(symbol = MaterialSymbols.AutoMirrored.Send, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_dm_start))
}
}
}
}
}
/** One tappable search result — avatar + resolved name — that adds the user as a recipient. */
@Composable
private fun SuggestionRow(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(hex, 34.dp, accountViewModel = accountViewModel, nav = nav)
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge)
}
}
/** A removable chip for one added recipient — avatar + resolved name + a clear affordance. */
@Composable
private fun ParticipantChip(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onRemove: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
InputChip(
selected = false,
onClick = onRemove,
label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
avatar = { UserPicture(hex, 22.dp, accountViewModel = accountViewModel, nav = nav) },
trailingIcon = {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.buzz_dm_remove), modifier = Modifier.size(16.dp))
},
)
}
@@ -0,0 +1,210 @@
/*
* 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.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* Backing ViewModel for [BuzzNewDmScreen]. It gathers 1-8 other participants and a Buzz
* workspace relay, publishes a kind-41010 open command, and reads the relay's synchronous OK
* (`response:{"channel_id":"…"}`) for the assigned [GroupId] — we never mint the DM's UUID
* ourselves, and the deployed relay does not emit a queryable kind-41001 to poll for.
*/
class BuzzNewDmViewModel : ViewModel() {
@Volatile private var account: Account? = null
private val _relay = MutableStateFlow<NormalizedRelayUrl?>(null)
val relay: StateFlow<NormalizedRelayUrl?> = _relay.asStateFlow()
private val _relays = MutableStateFlow<List<NormalizedRelayUrl>>(emptyList())
val relays: StateFlow<List<NormalizedRelayUrl>> = _relays.asStateFlow()
private val _participants = MutableStateFlow<List<HexKey>>(emptyList())
val participants: StateFlow<List<HexKey>> = _participants.asStateFlow()
/** The current typeahead query and its resolved candidate pubkeys (members of this workspace first). */
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _suggestions = MutableStateFlow<List<HexKey>>(emptyList())
val suggestions: StateFlow<List<HexKey>> = _suggestions.asStateFlow()
private var searchJob: Job? = null
private val _status = MutableStateFlow<Status>(Status.Idle)
val status: StateFlow<Status> = _status.asStateFlow()
sealed interface Status {
data object Idle : Status
data object Sending : Status
data class Error(
val message: String,
) : Status
}
/** Binds scoped to a single community [relayUrl] — a new DM is created on that community. */
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
this.account = account
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
_relays.value = listOfNotNull(relay)
_relay.value = relay
}
fun selectRelay(relay: NormalizedRelayUrl) {
_relay.value = relay
}
/**
* Updates the typeahead [text] and refreshes [suggestions] off the main thread. Members of
* this workspace's channels are surfaced first (they're the people you'd DM here), then the
* rest of the general user search. Already-added recipients and yourself are filtered out.
*/
fun updateQuery(text: String) {
_query.value = text
val account = account ?: return
searchJob?.cancel()
if (text.isBlank()) {
_suggestions.value = emptyList()
return
}
searchJob =
viewModelScope.launch(Dispatchers.IO) {
delay(150) // debounce keystrokes before touching the cache
val members = workspaceMemberKeys()
val me = account.userProfile().pubkeyHex
val already = _participants.value.toSet()
val ranked =
LocalCache
.findUsersStartingWith(text.trim(), account)
.asSequence()
.map { it.pubkeyHex }
.filter { it != me && it !in already }
// Stable sort keeps findUsersStartingWith's own relevance order within each bucket.
.sortedByDescending { it in members }
.take(12)
.toList()
_suggestions.value = ranked
}
}
/** The union of member + admin pubkeys across every channel this workspace relay hosts locally. */
private fun workspaceMemberKeys(): Set<HexKey> {
val relay = _relay.value ?: return emptySet()
val keys = HashSet<HexKey>()
LocalCache.getRelayGroupChannelsOnRelay(relay).forEach { channel ->
keys.addAll(channel.members)
channel.admins.forEach { keys.add(it.pubKey) }
}
return keys
}
/**
* Adds an already-resolved [hex] pubkey (a tapped search result). Returns an error string to
* surface, or null on success. Rejects me, duplicates, invalid keys and the 8-participant ceiling.
* Also clears the query so the suggestion list collapses after a pick.
*/
fun addParticipant(hex: HexKey): String? {
val account = account ?: return "Not ready"
if (!hex.isValid()) return "Not a valid key"
if (hex == account.userProfile().pubkeyHex) return "That's you"
val current = _participants.value
if (hex in current) return "Already added"
if (current.size >= DmOpenEvent.MAX_PARTICIPANTS) return "At most ${DmOpenEvent.MAX_PARTICIPANTS} others"
_participants.value = current + hex
_query.value = ""
_suggestions.value = emptyList()
return null
}
/**
* Resolves a pasted npub/hex [input] and adds it — the escape hatch for someone not yet in the
* local cache (so they never surface in [updateQuery]'s search). Returns an error or null.
*/
fun addRawKey(input: String): String? {
val hex = decodePublicKeyAsHexOrNull(input.trim())?.takeIf { it.isValid() } ?: return "Not a valid npub or hex key"
return addParticipant(hex)
}
fun removeParticipant(hex: HexKey) {
_participants.update { it - hex }
}
/**
* Publishes the 41010 and reads the relay's synchronous OK confirmation for the assigned
* channel id, then invokes [onOpened] with the [GroupId]. On a null id (the relay didn't
* confirm in the ack) it calls [onOpened] with null so the screen falls back to the inbox.
*/
fun start(onOpened: (GroupId?) -> Unit) {
val account = account ?: return
val relay =
_relay.value ?: run {
_status.value = Status.Error("Pick a workspace relay")
return
}
val others = _participants.value
if (others.isEmpty()) {
_status.value = Status.Error("Add at least one person")
return
}
_status.value = Status.Sending
viewModelScope.launch(Dispatchers.IO) {
try {
val channelId = account.openBuzzDm(relay, others)
val groupId = channelId?.let { GroupId(it, relay) }
withContext(Dispatchers.Main) { onOpened(groupId) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// openBuzzDm can throw signer / IO / timeout errors (not just IllegalArgumentException);
// catch them all so a failure surfaces as an error instead of crashing and leaving the
// Start button stuck disabled on `Sending`.
_status.value = Status.Error(e.message ?: "Could not open the DM")
}
}
}
}
@@ -0,0 +1,78 @@
/*
* 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.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
import com.vitorpamplona.quartz.buzz.presence.PresenceStatus
import com.vitorpamplona.quartz.nip01Core.core.HexKey
private val PresenceOnline = Color(0xFF4CAF50)
private val PresenceAway = Color(0xFFFFB300)
/** The latest Buzz presence for [pubkey], mapped to the curated enum, or null when none is known. */
@Composable
fun rememberBuzzPresence(pubkey: HexKey): PresenceStatus? {
val map by BuzzPresenceState.flow.collectAsStateWithLifecycle()
return remember(map, pubkey) { map[pubkey]?.let { PresenceStatus.fromWire(it) } }
}
/**
* A small status dot for a Buzz workspace member — green online, amber away — rendered nowhere for
* offline/unknown so the UI doesn't imply presence tracking where there is none. Meant to be
* overlaid on the bottom-end of an avatar inside a [Box] (pass `Modifier.align(...)`).
*/
@Composable
fun PresenceDot(
pubkey: HexKey,
modifier: Modifier = Modifier,
size: Dp = 11.dp,
ringColor: Color = Color.Unspecified,
) {
val color =
when (rememberBuzzPresence(pubkey)) {
PresenceStatus.ONLINE -> PresenceOnline
PresenceStatus.AWAY -> PresenceAway
// Offline / unknown: render nothing rather than a grey dot implying we track them.
else -> return
}
Box(
modifier =
modifier
.size(size)
.clip(CircleShape)
.then(if (ringColor != Color.Unspecified) Modifier.border(2.dp, ringColor, CircleShape) else Modifier)
.background(color, CircleShape),
)
}
@@ -0,0 +1,192 @@
/*
* 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.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Collections
/**
* Discovers the Buzz workspace channels the user already belongs to on a relay (joined via the
* Buzz web/desktop app) so they can add them to Amethyst. Hosted by the relay group-list screen,
* which folds this membership-scoped section in when the relay is a Buzz relay.
*
* Buzz membership is server-side (no NIP-29 kind-10009 join event), so a Buzz relay exposes no
* public group directory — the generic "browse a relay" flow returns nothing. Instead the relay
* addresses each member a kind-44100 member-added notification (`#p` = me). This warm-authenticates
* the relay (NIP-42) and reads those, then fetches each channel's kind-39000 metadata to keep only
* the **non-DM** workspace channels (DMs surface separately). Adding one calls [Account.follow] to
* append it to the user's kind-10009 list — which makes it appear in the Messages list and load at
* boot through the existing relay-group machinery, no separate registry needed.
*/
class BuzzRelayImportViewModel : ViewModel() {
@Volatile private var account: Account? = null
private var relay: NormalizedRelayUrl? = null
val relayUrl: NormalizedRelayUrl? get() = relay
sealed interface Status {
data object Loading : Status
data object Ready : Status
data class Error(
val message: String,
) : Status
}
private val _status = MutableStateFlow<Status>(Status.Loading)
val status: StateFlow<Status> = _status.asStateFlow()
/** The non-DM workspace channels the user is a member of on this relay. */
private val _channels = MutableStateFlow<List<GroupId>>(emptyList())
val channels: StateFlow<List<GroupId>> = _channels.asStateFlow()
/** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */
private val _added = MutableStateFlow<Set<String>>(emptySet())
val added: StateFlow<Set<String>> = _added.asStateFlow()
fun bind(
account: Account,
relayUrlStr: String,
) {
if (this.account != null) return
val normalized =
RelayUrlNormalizer.normalizeOrNull(relayUrlStr) ?: run {
_status.value = Status.Error("Invalid relay URL")
return
}
this.account = account
this.relay = normalized
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
val newlyJoined = BuzzWorkspaces.join(normalized)
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
// NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this
// join (the common case — the relay is in the user's lists and connected at startup), that
// challenge was spent while the relay was still NOT first-party, so the connection is
// unauthenticated and the persistent group-roster (39002) subscription is refused. Joining
// makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the
// relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and
// every other `#p=me`-gated read on the shared socket.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
// Seed "already added" from the current kind-10009 list so channels the user already has
// render as added rather than offering a duplicate Add.
_added.value =
account.relayGroupList.liveRelayGroupList.value
.filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized }
.mapTo(mutableSetOf()) { it.groupId }
discover(account, normalized)
}
private fun discover(
account: Account,
relay: NormalizedRelayUrl,
) {
viewModelScope.launch(Dispatchers.IO) {
_status.value = Status.Loading
try {
val myPubkey = account.userProfile().pubkeyHex
val channelIds = Collections.synchronizedSet(HashSet<String>())
// 1. Warm-auth then read the relay's kind-44100 member-added notifications for me.
account.client.fetchAllWithHooks(
filters =
mapOf(
relay to
listOf(
Filter(
kinds = listOf(MemberAddedNotificationEvent.KIND),
tags = mapOf("p" to listOf(myPubkey)),
),
),
),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
false
}
// 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load.
if (channelIds.isNotEmpty()) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
// 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived
// (type unknown) is optimistically shown as a workspace channel.
_channels.value =
channelIds
.mapNotNull { id ->
val groupId = GroupId(id, relay)
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
if (channel.event?.isBuzzDm() == true) null else groupId
}.sortedBy { it.id }
_status.value = Status.Ready
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_status.value = Status.Error(e.message ?: "Could not load your channels")
}
}
}
/** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */
fun add(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.follow(channel)
_added.update { it + groupId.id }
}
}
fun addAll() {
_channels.value.forEach(::add)
}
}
@@ -0,0 +1,171 @@
/*
* 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.screen.loggedIn.buzz
import android.content.Intent
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.buzz.BuzzInviteMinter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/**
* The Buzz workspace top-bar overflow (3-dot) menu. Holds the two workspace-owner actions that used
* to sit inline above the channel list: "Add people to this workspace" (kind-9030, delegated to the
* screen's [onAddPeople] dialog) and "Create invite link" (mints via the relay's `/api/invites`
* endpoint — see [BuzzInviteMinter]). Any member sees both, but the relay only serves owners/admins,
* so a rejection surfaces as the error dialog.
*/
@Composable
fun BuzzWorkspaceOverflowMenu(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
onAddPeople: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
var minting by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<BuzzInviteMinter.MintedInvite?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(24.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = null, modifier = Modifier.size(20.dp))
},
text = { Text(stringRes(R.string.buzz_community_add_people)) },
onClick = {
menuOpen = false
onAddPeople()
},
)
DropdownMenuItem(
leadingIcon = {
if (minting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Link, contentDescription = null, modifier = Modifier.size(20.dp))
}
},
text = { Text(stringRes(R.string.buzz_invite_create)) },
enabled = !minting,
onClick = {
menuOpen = false
if (minting) return@DropdownMenuItem
minting = true
error = null
scope.launch {
try {
result =
BuzzInviteMinter.mint(
relay = relay,
ttlSecs = null,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForPushRegistration,
httpAuth = accountViewModel.account::createHTTPAuthorization,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: e::class.simpleName
} finally {
minting = false
}
}
},
)
}
result?.let { minted ->
AlertDialog(
onDismissRequest = { result = null },
title = { Text(stringRes(R.string.buzz_invite_link_title)) },
text = {
SelectionContainer {
Text(minted.url, style = MaterialTheme.typography.bodyMedium)
}
},
confirmButton = {
TextButton(onClick = {
val send =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, minted.url)
}
ContextCompat.startActivity(context, Intent.createChooser(send, null), null)
result = null
}) { Text(stringRes(R.string.buzz_invite_share)) }
},
dismissButton = {
TextButton(onClick = {
clipboard.setText(AnnotatedString(minted.url))
result = null
}) { Text(stringRes(R.string.buzz_invite_copy)) }
},
)
}
error?.let { message ->
AlertDialog(
onDismissRequest = { error = null },
title = { Text(stringRes(R.string.buzz_invite_error_title)) },
text = { Text(message, color = MaterialTheme.colorScheme.error) },
confirmButton = {
TextButton(onClick = { error = null }) { Text(stringRes(R.string.buzz_invite_dismiss)) }
},
)
}
}
@@ -0,0 +1,58 @@
/*
* 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.screen.loggedIn.buzz
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
/**
* The Buzz forum-reply composer. Reuses the whole rich chat composer ([ChannelNewMessageViewModel] +
* `EditFieldRow`: emoji, mentions, media, drafts, typing) but overrides only the built event so a send
* publishes a kind-45003 [ForumCommentEvent] scoped to the open thread instead of a chat message.
*/
class ForumReplyNewMessageViewModel : ChannelNewMessageViewModel() {
private var forumRootId: HexKey? = null
/** Bind to the forum [channel] and the thread [rootId] this composer replies into. */
fun loadForumThread(
channel: RelayGroupChannel,
rootId: HexKey,
) {
load(channel)
forumRootId = rootId
}
override suspend fun createTemplate(): EventTemplate<out Event>? {
val forumChannel = channel as? RelayGroupChannel ?: return super.createTemplate()
val root = forumRootId ?: return super.createTemplate()
val text = message.text.toString().trim()
if (text.isEmpty()) return null
// A reply to another comment nests under it (parent = that comment); a top-level reply targets
// the root (parent == root), per ForumCommentEvent.build's root/parent contract.
val parent = replyTo.value?.idHex ?: root
val mentions = listOfNotNull(replyTo.value?.author?.pubkeyHex)
return ForumCommentEvent.build(forumChannel.groupId.id, text, rootEventId = root, parentEventId = parent, mentions = mentions)
}
}
@@ -0,0 +1,45 @@
/*
* 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.screen.loggedIn.chats
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
/**
* Whether [event] is a **minichat thread reply** — a reply that lives inside the thread opened from
* its parent message, NOT as a flat sibling in the main timeline. Two dialects express the same idea:
*
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
* - **Buzz workspaces**: a kind-40002 [StreamMessageV2Event] carrying a NIP-10 `reply`-marked `e` tag
* and NOT flagged `broadcast` (Buzz rejects kind-1111, so it threads chat with 40002 markers; a
* `broadcast=1` reply is an inline timeline sibling, matching block/buzz's `isThreadReply`).
*
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
* the minichat feed shows them — so all three agree on one definition.
*/
fun isMinichatReply(event: Event?): Boolean =
when (event) {
is CommentEvent -> true
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
else -> false
}
@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -263,6 +268,7 @@ fun ChatFeedLoaded(
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditBuzz = onWantsToEditBuzz,
)
}
}
@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
var showShareSheet by remember { mutableStateOf(false) }
var wantsToEditPost by remember { mutableStateOf(false) }
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
// Stage one: the primary chat action (reply / edit draft) is always shown.
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
// authorship restricts it to my own messages.
val canEditBuzz =
onWantsToEditBuzz != null &&
note.event is StreamMessageV2Event &&
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
if (canEditBuzz) {
SectionDivider()
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
onWantsToEditBuzz!!(note)
onDismiss()
}
}
}
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },
@@ -56,6 +56,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
@@ -65,8 +70,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -104,6 +114,9 @@ fun ChatroomMessageCompose(
// reply quotes inside a DM, where the target is simply older than the loaded window (see
// LoadingReplyNote). Null keeps the default blank for every other caller.
onBlank: (@Composable () -> Unit)? = null,
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
// every non-Buzz chat surface, which hides the action.
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
// chat message renders with the chat reply design instead of the quoted-note card.
@@ -129,6 +142,19 @@ fun ChatroomMessageCompose(
RenderChatClip(baseNote, accountViewModel, nav)
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} else if (event is SystemMessageEvent) {
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
RenderBuzzSystemMessage(baseNote)
} else if (event is StreamMessageDiffEvent) {
// Buzz kind-40008: a code/text diff pushed into the channel.
RenderBuzzDiff(baseNote)
} else if (event is ForumVoteEvent) {
// Buzz kind-45002: a forum up/down vote.
RenderBuzzForumVote(baseNote)
} else if (isBuzzActivityRow(event)) {
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
// especially must be caught here — their content is JSON, not chat text.
RenderBuzzActivityRow(baseNote)
} else {
NormalChatNote(
baseNote,
@@ -145,6 +171,7 @@ fun ChatroomMessageCompose(
onHighlightFinished,
groupPosition,
previousNoteId,
onWantsToEditBuzz,
)
}
}
@@ -175,6 +202,7 @@ fun NormalChatNote(
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
previousNoteId: HexKey? = null,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
@@ -305,6 +333,7 @@ fun NormalChatNote(
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
onWantsToEditBuzz = onWantsToEditBuzz,
)
},
reactionsRow =
@@ -582,7 +611,17 @@ fun NoteRow(
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> {
// Buzz channels overlay kind-40003 edits on their messages: when one
// exists, render the newest edit's content instead of the stale
// original. Null for every non-Buzz chat surface.
val buzzEdit = observeBuzzEdit(note)
if (buzzEdit != null) {
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
} else {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
}
}
}
@@ -0,0 +1,270 @@
/*
* 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.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited or has no channel scope.
*
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
* (a Buzz UUID) rather than any channel object: the state exists independently of
* when — or whether — the channel materialized, so a row composed before the first
* edit arrived still starts rendering overlays the moment one lands.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
// Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance
// would cache a null groupId taken before the event populated and never recompute for that row.
val channelId = remember(note.event) { note.event?.groupId() } ?: return null
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Subscribing to the version counter is what re-runs editFor on new arrivals.
val version by state.editUpdates.collectAsState()
return remember(note, version) { state.editFor(note.idHex) }
}
/**
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, mirroring Buzz's own last-write-wins presentation.
*/
@Composable
fun RenderBuzzEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
innerQuote: Boolean,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The edit note may still be loading; fall back to the original rendering rather
// than committing to an edited branch that would show a blank row.
val content = editNote.event?.content
if (content == null) {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
return
}
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column {
TranslatableRichTextViewer(
content = content,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
Text(
text = stringRes(R.string.buzz_message_edited),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
/**
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
* narrates the room rather than speaking in it, so it renders as a centered system
* line like the NIP-28 admin events, from the relay-signed JSON payload.
*/
@Composable
fun RenderBuzzSystemMessage(note: Note) {
val event = note.event as? SystemMessageEvent ?: return
// Relay-emitted machine text (join/leave/topic); shown as-is rather than through
// string resources — the payload vocabulary is Buzz's, not ours to translate yet.
val text =
remember(event) {
val payload = event.payload()
when (payload?.type) {
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
else -> payload?.type?.replace('_', ' ')
} ?: event.content.take(120)
}
ChatSystemMessage(text = text)
}
/**
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
* narrates as a centered system line rather than a chat bubble. Huddle events in
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
* chat message would show raw `{"ephemeral_channel_id":…}`.
*/
fun isBuzzActivityRow(event: Event?): Boolean =
event is JobRequestEvent ||
event is JobAcceptedEvent ||
event is JobProgressEvent ||
event is JobResultEvent ||
event is JobCancelEvent ||
event is JobErrorEvent ||
event is HuddleStartedEvent ||
event is HuddleParticipantJoinedEvent ||
event is HuddleParticipantLeftEvent ||
event is HuddleEndedEvent
/**
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
* label is derived from the kind; job progress/result/error also append a short content
* snippet (the human-readable status/result/error the agent wrote).
*/
@Composable
fun RenderBuzzActivityRow(note: Note) {
val event = note.event ?: return
val text =
remember(event) {
when (event) {
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
is JobAcceptedEvent -> "⚙ job accepted"
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
is JobResultEvent -> "⚙ job result" + event.result().snippet()
is JobCancelEvent -> "⚙ job cancelled"
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
is HuddleStartedEvent -> "🔊 huddle started"
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
is HuddleEndedEvent -> "🔊 huddle ended"
else -> event.content.take(120)
}
}
ChatSystemMessage(text = text)
}
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
private fun String.snippet(): String {
val oneLine = trim().replace('\n', ' ')
if (oneLine.isEmpty()) return ""
return ": " + if (oneLine.length > 80) oneLine.take(80) + "…" else oneLine
}
/**
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
* `+`/`-` gutters into wrapped prose).
*/
@Composable
fun RenderBuzzDiff(note: Note) {
val event = note.event as? StreamMessageDiffEvent ?: return
val meta = remember(event) { event.diffMeta() }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
val header =
remember(meta) {
meta?.let {
buildString {
it.filePath?.let(::append)
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
it.prNumber?.let { pr -> append(" · PR #$pr") }
it.commitSha
?.takeIf { sha -> sha.isNotBlank() }
?.let { sha -> append(" · ${sha.take(8)}") }
}.ifBlank { null }
}
}
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (meta?.truncated == true) {
Text(
text = stringRes(R.string.buzz_diff_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
}
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
@Composable
fun RenderBuzzForumVote(note: Note) {
val event = note.event as? ForumVoteEvent ?: return
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
ChatSystemMessage(text = text)
}
@@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
@@ -57,7 +57,7 @@ class MinichatFeedViewModel(
private fun collectReplies(): List<Note> =
rootNote.replies
.filter { it.event is CommentEvent && account.isAcceptable(it) }
.filter { isMinichatReply(it.event) && account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
class Factory(
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatro
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DmReportWarningCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -258,6 +259,8 @@ fun ChatroomViewUI(
Column(Modifier.fillMaxHeight()) {
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
DmReportWarningCard(room, accountViewModel, nav)
Column(
modifier =
Modifier
@@ -0,0 +1,321 @@
/*
* 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.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportWarningState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.types.reportTypeLabels
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size36dp
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
/** Reporter avatars shown before the row collapses into a "+N" overflow chip. */
private const val MAX_REPORTER_AVATARS = 6
/**
* Warns that the person on the other end of a 1:1 DM has been reported by someone the user follows.
*
* Collapsed it states the count and, when the reports agree on one reason, that reason. The reporters
* themselves stay behind a tap so an accusation is visible only when the user asks for it. Group rooms
* render nothing — the warning names one person.
*/
@Composable
fun DmReportWarningCard(
room: ChatroomKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val counterpartHex = room.users.singleOrNull() ?: return
LoadUser(baseUserHex = counterpartHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
val flow = remember(user) { accountViewModel.createUserReportWarningFlow(user) }
val state by flow.collectAsStateWithLifecycle()
key(counterpartHex) {
// Hoisted above the `shouldWarn` check so it survives the transient SILENT state the
// flow emits on ~20s of backgrounding (`WhileSubscribed(10000, 10000)` +
// lifecycle-driven unsubscribe at ON_STOP) rather than being lost when this
// `remember` group briefly leaves composition.
var dismissed by remember { mutableStateOf(false) }
if (state.shouldWarn && !dismissed) {
ReportWarningBody(state, accountViewModel, nav) { dismissed = true }
}
}
}
}
}
/**
* Restrained Material 3 banner: the alert rides on a neutral `surfaceContainer` card and carries its
* severity through the `error` color role — a tinted report badge, an error headline, and a
* filled-tonal primary action — rather than flooding the whole header in `errorContainer`. Themes for
* free: every color resolves from the scheme, so light and dark need no branching here.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ReportWarningBody(
state: UserReportWarningState,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val typeLabels = reportTypeLabels(state.types)
// Reason chips: the single agreed reason stays visible collapsed; the full list appears on expand.
val chipLabels = if (expanded || typeLabels.size == 1) typeLabels else emptyList()
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ReportBadge()
Column {
Text(
text = reportWarningHeadline(state),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.dm_sender_reported_subtitle),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
if (chipLabels.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
chipLabels.forEach { ReasonChip(it) }
}
}
if (expanded) {
ReporterCluster(state, accountViewModel, nav)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant),
) {
Text(text = stringRes(R.string.dm_sender_reported_dismiss))
}
FilledTonalButton(
onClick = { expanded = !expanded },
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.14f),
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(
text =
if (expanded) {
stringRes(R.string.dm_sender_reported_hide)
} else {
stringRes(R.string.dm_sender_reported_who)
},
)
}
}
}
}
}
/** Error-tinted circular badge carrying the report glyph — the card's single splash of red. */
@Composable
private fun ReportBadge() {
Box(
modifier =
Modifier
.size(Size36dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Report,
contentDescription = stringRes(R.string.dm_sender_reported_icon),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(Size20dp),
)
}
}
/** Outlined error chip naming one report reason. */
@Composable
private fun ReasonChip(label: String) {
val error = MaterialTheme.colorScheme.error
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = error,
modifier =
Modifier
.border(1.dp, error.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
/**
* Overlapping reporter avatars, capped at [MAX_REPORTER_AVATARS] and spilling the remainder into a
* "+N" chip so a busy account doesn't render a wall of faces. Each avatar wears a card-colored ring
* so the overlap reads cleanly.
*/
@Composable
private fun ReporterCluster(
state: UserReportWarningState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val shown = state.reports.take(MAX_REPORTER_AVATARS)
val overflow = state.reporterCount - shown.size
Row(
modifier = Modifier.padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy((-8).dp),
verticalAlignment = Alignment.CenterVertically,
) {
shown.forEach { report ->
Box(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surfaceContainer, CircleShape)
.padding(2.dp),
) {
NoteAuthorPicture(
baseNote = report,
size = Size30dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
if (overflow > 0) {
Spacer(DoubleHorzSpacer)
OverflowChip(overflow)
}
}
}
/** The "+N" pill closing the capped reporter row. */
@Composable
private fun OverflowChip(count: Int) {
Box(
modifier =
Modifier
.height(Size30dp)
.defaultMinSize(minWidth = Size30dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.dm_sender_reported_more_count, count),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
/** "Reported by 2 people you follow". The reason, if any, is rendered separately. */
@Composable
private fun reportWarningHeadline(state: UserReportWarningState): String =
pluralStringResource(
R.plurals.dm_sender_reported,
state.reporterCount,
state.reporterCount,
)
/**
* The headline followed by the reasons as separate sentences, for the chat-list row's screen reader
* label — where there is no second line to put them on. Sentence-separated rather than spliced, for
* the same translation reason the headline avoids interpolation.
*/
@Composable
fun reportWarningContentDescription(state: UserReportWarningState): String {
val headline = reportWarningHeadline(state)
val typeLabels = reportTypeLabels(state.types)
return remember(headline, typeLabels) {
if (typeLabels.isEmpty()) headline else headline + ". " + typeLabels.joinToString(", ")
}
}
@@ -109,6 +109,11 @@ fun RoomNameDisplay(
room: ChatroomKey,
modifier: Modifier,
accountViewModel: AccountViewModel,
// The 1:1 counterpart, resolved once per row by the caller. Null for group rooms, which
// resolve every member on their own via DisplayUserSetAsSubject. Deliberately has no default:
// this function no longer self-resolves, so a caller must decide explicitly rather than
// silently rendering a blank 1:1 name.
preloadedUser: User?,
) {
val roomSubject by accountViewModel.account.chatroomList
.getOrCreatePrivateChatroom(room)
@@ -120,7 +125,11 @@ fun RoomNameDisplay(
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
DisplayUserAndSubject(it, accountViewModel, preloadedUser)
}
} else if (room.users.size == 1) {
Row {
preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
}
} else {
DisplayUserSetAsSubject(room, accountViewModel)
@@ -144,9 +153,9 @@ fun DisplayRoomSubject(
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String,
accountViewModel: AccountViewModel,
preloadedUser: User?,
) {
Row {
Text(
@@ -160,9 +169,7 @@ private fun DisplayUserAndSubject(
fontWeight = FontWeight.Bold,
maxLines = 1,
)
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
it?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
}
preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
}
}
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
class ChannelFeedFilter(
val channel: Channel,
@@ -37,10 +37,10 @@ class ChannelFeedFilter(
override fun changesFlow() = channel.changesFlow()
// A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its
// root message, not as a flat sibling in the main timeline (an inline reply is a normal
// kind-9/42 message and stays). Everything else the channel gathered is a timeline message.
private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note)
// A minichat reply — a kind-1111 comment, or a Buzz 40002 thread reply (reply-marked, non-broadcast)
// — lives in the thread opened from its root message, not as a flat sibling in the main timeline.
// An inline reply (kind-9/42, or a Buzz broadcast=1 reply) is a normal message and stays.
private fun isTimelineMessage(note: Note): Boolean = !isMinichatReply(note.event) && account.isAcceptable(note)
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) })
@@ -0,0 +1,187 @@
/*
* 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.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
* it slides in when someone starts typing and out when they stop (heartbeats age out of
* the freshness window). Own typing is filtered by [myPubkey].
*
* The three animated dots are a small modern flourish; the wording collapses to a count
* once more than two people type at once so the row never overflows.
*/
@Composable
fun BuzzTypingIndicator(
channelId: String,
myPubkey: HexKey,
accountViewModel: AccountViewModel,
) {
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
// but only while this channel actually has any — an idle channel never wakes the loop.
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
val raw = typingByChannel[channelId]
LaunchedEffect(channelId, raw) {
if (raw.isNullOrEmpty()) return@LaunchedEffect
while (true) {
nowSecs = TimeUtils.now()
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
delay(2000L)
}
}
val typers =
remember(raw, myPubkey, nowSecs) {
(raw ?: emptyMap())
.filterKeys { it != myPubkey }
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
.keys
.sorted()
}
AnimatedVisibility(
visible = typers.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TypingDots()
Spacer(Modifier.width(8.dp))
Text(
text = typingLabel(typers, accountViewModel),
style = MaterialTheme.typography.labelSmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
}
@Composable
private fun typingLabel(
typers: List<HexKey>,
accountViewModel: AccountViewModel,
): String =
when (typers.size) {
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
2 ->
stringRes(
R.string.buzz_typing_two,
rememberTypistName(typers[0], accountViewModel),
rememberTypistName(typers[1], accountViewModel),
)
else -> stringRes(R.string.buzz_typing_many)
}
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
@Composable
private fun TypingDots() {
val transition = rememberInfiniteTransition(label = "buzz-typing")
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
repeat(3) { index ->
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 900, delayMillis = index * 150),
repeatMode = RepeatMode.Reverse,
),
label = "dot-$index",
)
Spacer(
modifier =
Modifier
.size(5.dp)
.graphicsLayer {
translationY = -3f * phase
val s = 0.7f + 0.3f * phase
scaleX = s
scaleY = s
}.alpha(0.4f + 0.6f * phase)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberTypistName(
hex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
val info by observeUserInfo(user, accountViewModel)
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
}
@@ -25,57 +25,92 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.pluralStringResource
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
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.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
/**
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
* can browse and open channels on that relay. The relay's directory is streamed by
@@ -93,18 +128,30 @@ fun RelayGroupChannelListScreen(
RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel)
// Trust state drives the Tor→clearnet hint below AND a NIP-11 re-fetch: marking a relay Trusted
// moves it off Tor onto clearnet, so a NIP-11 doc that failed over Tor must be re-fetched — its
// cached error would otherwise keep the relay's `self` unknown for the whole TTL.
val trustedRelays by accountViewModel.account.trustedRelayList.flow
.collectAsStateWithLifecycle()
val isTrusted = relay in trustedRelays
// Warm the relay's NIP-11 so we can tell its genuine (relay-signed) groups from stray
// user-published 39000s that a non-NIP-29 relay may also be storing.
val relayInfo by loadRelayInfo(relay)
// user-published 39000s that a non-NIP-29 relay may also be storing. Re-keyed on trust so a move
// to clearnet re-fetches over the new transport instead of serving the cached over-Tor failure.
val nip11Cache = Amethyst.instance.nip11Cache
val relayInfo by produceState(nip11Cache.getFromCache(relay), relay, isTrusted) {
if (isTrusted) nip11Cache.invalidate(relay)
nip11Cache.loadRelayInfo(relay, onInfo = { value = it }, onError = { _, _, _ -> })
}
// Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in
// the cache — driven by LocalCache.observeEvents rather than a timer, so the list
// updates as directory events arrive with no polling. The initial value is sorted too
// so the first frame doesn't reshuffle when the first emission arrives.
val allChannels by produceState(
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() },
relay,
) {
// remember the seed so the per-relay cache scan + sort runs once (on first composition / relay
// change), not on every recomposition — produceState evaluates its initialValue argument eagerly.
val initialChannels = remember(relay) { accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() } }
val allChannels by produceState(initialValue = initialChannels, relay) {
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.collect {
@@ -112,9 +159,102 @@ fun RelayGroupChannelListScreen(
}
}
// Only the relay's own genuine, relay-signed groups (39000 author == the relay's NIP-11 `self`).
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden.
val channels = remember(allChannels, relayInfo) { allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } }
// Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`).
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if
// NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while
// still serving events over the socket), fall back to the dominant 39000 signer on this relay as
// its de-facto signer — so its relay-signed groups still show while a stray user-published 39000
// (a different author) stays filtered.
val channels =
remember(allChannels, relayInfo) {
val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null
if (nip11Known) {
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
} else {
val dominantSigner =
allChannels
.mapNotNull { it.event?.pubKey }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
}
}
// Buzz relays expose no public group directory (membership is server-side), so `channels` above
// stays empty for them. When this is a Buzz relay, fold in the membership-scoped channels
// (kind-44100) so browsing the relay lists the channels you already belong to — each addable to
// your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse.
val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle()
val buzzAdded by buzzVm.added.collectAsStateWithLifecycle()
val buzzStatus by buzzVm.status.collectAsStateWithLifecycle()
// This community's recent Direct Messages, shown inline below the channels (like Buzz's own
// sidebar) instead of behind a separate drawer entry. Only mounted for Buzz relays.
val dmVm: BuzzDmListViewModel = viewModel(key = "BuzzDmInline-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) dmVm.bind(accountViewModel.account, relay.url) }
val dmRows by dmVm.rows.collectAsStateWithLifecycle()
// A Buzz workspace's channels come in three flavours, distinguished by the relay-signed 39000
// `channel_type`: chat "stream" channels, "forum" channels (threaded posts), and "dm" channels
// (private — they belong in the Direct Messages section, never the channel list). Split the
// membership set accordingly; look each id up in the full per-relay channel set (which also
// carries the directory 39000s) so the split reacts as metadata lands. Union in the directory
// ids so nothing the old flat list showed disappears.
val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } }
val buzzGroupIds =
remember(buzzChannels, channels) {
val seen = LinkedHashSet<String>()
(buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) }
}
fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType()
// Starred channels float to the top of their section (stable sort keeps the alphabetical order
// within the starred and unstarred buckets).
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val buzzChatChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
.sortedByDescending { it.id in starred }
}
val buzzForumChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
.sortedByDescending { it.id in starred }
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
fun toggleSection(key: String) {
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
}
// Community add-member (kind-9030). Offered to members of a Buzz workspace; the relay enforces
// the owner/admin requirement (a non-admin's command is simply rejected), since our NIP-43
// roster read drops roles and can't gate precisely.
var showAddPeople by remember { mutableStateOf(false) }
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
// Relay List (connected over clearnet even while Tor stays on for everything else).
val torType by Amethyst.instance.torPrefs.torType
.collectAsStateWithLifecycle(TorType.OFF)
val isOnion = remember(relay) { relay.url.contains(".onion") }
var connectTimedOut by remember(relay) { mutableStateOf(false) }
LaunchedEffect(relay) {
delay(TOR_CLEARNET_HINT_DELAY_MS)
connectTimedOut = true
}
val scope = rememberCoroutineScope()
val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut
Scaffold(
topBar = {
@@ -128,6 +268,15 @@ fun RelayGroupChannelListScreen(
)
},
popBack = nav::popBack,
actions = {
if (isBuzz) {
BuzzWorkspaceOverflowMenu(
relay = relay,
accountViewModel = accountViewModel,
onAddPeople = { showAddPeople = true },
)
}
},
)
},
floatingActionButton = {
@@ -141,34 +290,353 @@ fun RelayGroupChannelListScreen(
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
if (channels.isEmpty()) {
// A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct
// Messages, Agent Console) even before anything loads, rather than the generic "empty" text.
if (channels.isEmpty() && !isBuzz) {
// An empty directory on a relay whose NIP-11 says it doesn't run NIP-29 is almost
// certainly the wrong relay, not a young one — say so instead of the generic empty text.
val notNip29 = looksLikeNonNip29Relay(relayInfo)
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
text =
if (notNip29) {
stringRes(R.string.relay_group_channels_not_nip29)
} else {
stringRes(R.string.relay_group_channels_empty)
Column(Modifier.fillMaxSize().padding(padding)) {
if (showTorHint) {
TorClearnetBanner(
relayName = relay.displayUrl(),
onUseClearnet = {
scope.launch { accountViewModel.account.saveTrustedRelayList((trustedRelays + relay).toList()) }
},
style = MaterialTheme.typography.bodyMedium,
color = if (notNip29) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(32.dp),
)
)
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = if (notNip29) stringRes(R.string.relay_group_channels_not_nip29) else stringRes(R.string.relay_group_channels_empty),
style = MaterialTheme.typography.bodyMedium,
color = if (notNip29) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(32.dp),
)
}
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
if (showTorHint) {
item(key = "tor-hint") {
TorClearnetBanner(
relayName = relay.displayUrl(),
onUseClearnet = {
scope.launch { accountViewModel.account.saveTrustedRelayList((trustedRelays + relay).toList()) }
},
)
}
}
if (isBuzz) {
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
if (noChannelsYet) {
item(key = "buzz-no-channels") {
Text(
text =
if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
stringRes(R.string.buzz_import_loading)
} else {
stringRes(R.string.buzz_import_empty_body)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(24.dp),
)
}
}
// -- CHANNELS --
if (buzzChatChannels.isNotEmpty()) {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = { toggleSection("channels") },
) {
if (buzzChatChannels.any { it.id !in buzzAdded }) {
FilledTonalButton(onClick = { buzzVm.addAll() }) {
Text(stringRes(R.string.buzz_import_add_all))
}
}
}
}
if (!channelsCollapsed) {
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
)
}
}
}
// -- FORUMS --
if (buzzForumChannels.isNotEmpty()) {
val forumsCollapsed = "forums" in collapsedSections
item(key = "sec-forums") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = { toggleSection("forums") },
)
}
if (!forumsCollapsed) {
items(buzzForumChannels, key = { "forum-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
accountViewModel = accountViewModel,
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
)
}
}
}
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
item(key = "sec-dms") {
RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) {
IconButton(onClick = { nav.nav(Route.BuzzNewDm(relay.url)) }) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.buzz_dm_new),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
if (dmRows.isEmpty()) {
item(key = "dm-empty") {
Text(
text = stringRes(R.string.buzz_dm_section_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
)
}
} else {
val shown = dmRows.take(INLINE_DM_LIMIT)
items(shown, key = { "dm-${it.channelId}" }) { row ->
BuzzDmInlineRow(row, myPubkey, accountViewModel, nav) {
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
}
}
if (dmRows.size > INLINE_DM_LIMIT) {
val extra = dmRows.size - INLINE_DM_LIMIT
item(key = "dm-see-all") {
SeeAllRow(pluralStringResource(R.plurals.buzz_dm_see_all_count, extra, extra)) {
nav.nav(Route.BuzzDmList(relay.url))
}
}
}
}
// -- AGENT CONSOLE -- (owner's per-community fleet console, footer)
item(key = "sec-console") {
AgentConsoleFooter { nav.nav(Route.AgentConsole(relay.url)) }
}
} else {
// Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console).
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
}
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
}
}
}
}
if (showAddPeople) {
BuzzAddPeopleDialog(
title = stringRes(R.string.buzz_community_add_people),
accountViewModel = accountViewModel,
nav = nav,
isAlreadyIn = { BuzzCommunityMembership.isMember(relay, it) },
onAdd = { accountViewModel.addCommunityMember(relay, it) },
onDismiss = { showAddPeople = false },
)
}
}
/** A first screen's worth of a community's DMs shown inline; the rest live behind the See-all row. */
private const val INLINE_DM_LIMIT = 6
/**
* Shown on the relay screen when a relay won't load over Tor (e.g. a Cloudflare-fronted relay that
* blocks Tor exits). Offers to reach it over clearnet, which adds it to the Trusted Relay List.
*/
@Composable
private fun TorClearnetBanner(
relayName: String,
onUseClearnet: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.relay_tor_clearnet_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.size(6.dp))
Text(
text = stringRes(R.string.relay_tor_clearnet_body, relayName),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.size(10.dp))
FilledTonalButton(onClick = onUseClearnet, modifier = Modifier.align(Alignment.End)) {
Text(stringRes(R.string.relay_tor_clearnet_action))
}
}
}
}
/**
* A section divider label ("Channels", "Forums", "Direct messages") with an optional trailing
* action (an Add-all button, a New-message icon) — the modern equivalent of the old flat headers.
*/
@Composable
private fun RelayGroupSectionHeader(
title: String,
collapsed: Boolean = false,
onToggle: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(start = 16.dp, end = 8.dp, top = 18.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (onToggle != null) {
// A single chevron rotated 90° when expanded, so no extra glyph is needed.
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp).rotate(if (collapsed) 0f else 90f),
)
}
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
}
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM) and a compact last-activity time. Tapping opens the
* DM as its relay-group chat.
*/
@Composable
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val others = row.others.ifEmpty { listOf(myPubkey) }
val leadHex = others.first()
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
val leadName by observeUserName(leadUser, accountViewModel)
val label = if (others.size > 1) "$leadName +${others.size - 1}" else leadName
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Only show a presence dot for a 1:1 DM — a cluster avatar can't carry one peer's status.
Box {
UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav)
if (others.size == 1) {
PresenceDot(leadHex, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface)
}
}
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (row.lastActivity > 0) {
Text(
text = timeAgoShort(row.lastActivity, stringRes(R.string.now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** The "See all N conversations" row that opens the full per-community DM inbox. */
@Composable
private fun SeeAllRow(
label: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(text = label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
}
}
/** The owner's per-community Agent Console entry, pinned as the footer of a Buzz community view. */
@Composable
private fun AgentConsoleFooter(onClick: () -> Unit) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp))
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text(stringRes(R.string.buzz_console_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(
stringRes(R.string.buzz_console_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
}
}
@Composable
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -216,6 +217,7 @@ private fun ChannelView(
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
onWantsToEditBuzz = newPostModel::editBuzzMessage,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
@@ -251,11 +253,23 @@ private fun ChannelView(
)
}
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
// Buzz-only); a no-op otherwise.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
BuzzTypingIndicator(
channelId = channel.groupId.id,
myPubkey = accountViewModel.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the
// relay-signed roster (39001/39002) lists me as a member/mod/admin. Otherwise, explain why.
val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
// Show the composer wherever the relay would actually accept my write: any roster
// member/mod/admin, plus any authenticated member of an open Buzz channel (which accepts
// writes without a per-channel join). Otherwise explain why. See [RelayGroupChannel.canPost].
val canPost = liveChannel.canPost(accountViewModel.userProfile().pubkeyHex)
if (canPost) {
EditFieldRow(
@@ -412,7 +412,7 @@ private fun RelayGroupDiscoveryCard(
}
}
if (!isMember) {
if (!isMember && channel.requiresMembershipToPost()) {
FilledTonalButton(onClick = {
// Open groups join directly; closed ones open the group where the invite
// code can be entered.
@@ -579,7 +579,9 @@ private const val DISCOVERY_MESSAGE_CAP = 50
private fun DiscoveryStatusPill(channel: RelayGroupChannel) {
val label =
when {
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
// `closed` alone doesn't mean invite-only on Buzz (it stamps every channel); only badge
// it where membership is actually required to participate. See [RelayGroupChannel.requiresMembershipToPost].
channel.requiresMembershipToPost() && channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
else -> return
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,11 +32,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -43,32 +47,46 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzAgentActivityState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
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.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001)
@@ -121,6 +139,21 @@ private fun RelayGroupMembers(
val iCanModerate = channel.membershipOf(myPubkey).canModerate()
val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN
// Feed the process-wide agent-activity state while this screen is open so a "Working…" line
// can light up next to an agent that's currently running. Observer frames (24200) are
// ephemeral and `#p`-addressed to the owner, so only the owner ever sees them — a non-owner
// simply never records anything and no indicator shows. Buzz relays only.
val relay = channel.groupId.relayUrl
LaunchedEffect(relay, myPubkey) {
if (!BuzzRelayDialect.isBuzz(relay)) return@LaunchedEffect
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
accountViewModel.account.client.subscribeAsFlow(relay, filter).collect { events ->
events.filterIsInstance<ObserverFrameEvent>().forEach { frame ->
BuzzAgentActivityState.record(frame.agentPubKey() ?: frame.pubKey, frame.createdAt)
}
}
}
// Admins/moderators first, then plain members; alphabetical only inside a rank
// is overkill here — rely on relay order but push elevated roles to the top.
val roster =
@@ -132,6 +165,8 @@ private fun RelayGroupMembers(
.sortedBy { it.membership.rank() }
}
var showAddMember by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
@@ -155,6 +190,14 @@ private fun RelayGroupMembers(
popBack = nav::popBack,
)
},
// Only a moderator can add a member (the relay rejects a kind-9000 from anyone else).
floatingActionButton = {
if (iCanModerate) {
FloatingActionButton(onClick = { showAddMember = true }) {
Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member))
}
}
},
) { padding ->
if (roster.isEmpty()) {
// The roster always has at least the group's admins once it loads, so an
@@ -181,6 +224,17 @@ private fun RelayGroupMembers(
}
}
}
if (showAddMember) {
BuzzAddPeopleDialog(
title = stringRes(R.string.relay_group_add_member),
accountViewModel = accountViewModel,
nav = nav,
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) },
onDismiss = { showAddMember = false },
)
}
}
/** Sort key so admins float above moderators above plain members. */
@@ -216,7 +270,10 @@ private fun RelayGroupMemberRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
Box {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
PresenceDot(entry.pubkey, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface)
}
Column(Modifier.weight(1f)) {
if (user != null) {
@@ -229,6 +286,9 @@ private fun RelayGroupMemberRow(
overflow = TextOverflow.Ellipsis,
)
}
// A live "Working…" line for an agent member currently emitting observer frames;
// tapping it opens this community's Agent Console (Observer tab).
BotWorkingLabel(entry.pubkey) { nav.nav(Route.AgentConsole(channel.groupId.relayUrl.url)) }
}
MemberRoleBadge(entry)
@@ -350,6 +410,48 @@ private fun RelayGroupMemberRow(
}
}
/** How recent an observer frame must be for an agent to read as "Working…" right now. */
private const val BOT_WORKING_WINDOW_SECS = 90L
/**
* A live "Working…" line shown for an agent member that emitted an observer frame within the last
* [BOT_WORKING_WINDOW_SECS]. Renders nothing for a normal member (no frames) or a stale agent, and
* re-checks freshness on a slow tick so it fades out when the agent goes quiet.
*/
@Composable
private fun BotWorkingLabel(
agent: HexKey,
onClick: () -> Unit,
) {
val activity by BuzzAgentActivityState.flow.collectAsStateWithLifecycle()
val last = activity[agent] ?: return
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
// Tick only while the agent could still read as "working"; stop once stale so an idle agent
// doesn't wake this row every 4s forever. A newer frame changes `last` and restarts the tick.
LaunchedEffect(agent, last) {
while (TimeUtils.now() - last <= BOT_WORKING_WINDOW_SECS) {
nowSecs = TimeUtils.now()
delay(4_000)
}
nowSecs = TimeUtils.now()
}
if (nowSecs - last > BOT_WORKING_WINDOW_SECS) return
Row(
modifier = Modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(6.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
Text(
text = stringRes(R.string.buzz_agent_working),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
/**
* A small colored pill for an elevated role; plain members get nothing.
*
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.R
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.buzz.BuzzRelayDialect
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
@@ -68,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -117,9 +119,9 @@ private fun RelayGroupThreads(
RelayGroupThreadsPaging(threadCount = { threads.size }, listState = listState, history = history)
// 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()
// Hide the compose FAB where the relay would reject the kind-11: on membership-gated groups
// that don't list me. Open Buzz channels accept any authenticated member. See [RelayGroupChannel.canPost].
val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex)
Scaffold(
topBar = {
@@ -148,12 +150,19 @@ private fun RelayGroupThreads(
if (canPost) {
FloatingActionButton(
onClick = {
nav.nav(
Route.NewShortNote(
groupThreadId = channel.groupId.id,
groupThreadRelayUrl = channel.groupId.relayUrl.url,
),
)
// On a Buzz workspace, "new thread" is a Buzz forum post (kind 45001);
// vanilla NIP-29 relays use a kind-11 thread. Buzz relays reject
// unknown kinds, so a kind-11 thread would be refused there.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
nav.nav(Route.BuzzForumPost(channel.groupId.id, channel.groupId.relayUrl.url))
} else {
nav.nav(
Route.NewShortNote(
groupThreadId = channel.groupId.id,
groupThreadRelayUrl = channel.groupId.relayUrl.url,
),
)
}
},
shape = CircleShape,
) {
@@ -181,7 +190,16 @@ private fun RelayGroupThreads(
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
// A Buzz forum root (45001) opens the forum-thread detail (root + kind-45003 replies);
// a NIP-29 kind-11 thread opens the generic note view with its kind-1111 comment tree.
val open: () -> Unit = {
if (thread.event is ForumPostEvent) {
nav.nav(Route.BuzzForumThread(channel.groupId.id, channel.groupId.relayUrl.url, thread.idHex))
} else {
nav.nav(Route.Note(thread.idHex))
}
}
ThreadRow(thread, accountViewModel, nav, open)
}
item(key = "threads-history-footer") {
RelayGroupThreadsHistoryFooter(loadingOlder, status.exhausted)
@@ -257,17 +275,32 @@ 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).
// Observe the reply count so a 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 =
event
?.content
?.replace('\n', ' ')
?.trim()
.orEmpty()
val untitled = stringRes(R.string.relay_group_thread_untitled)
// Two thread shapes share this list: NIP-29 kind-11 threads (title + body) and Buzz forum
// roots (kind 45001, body only — no title). For a forum post, surface the first line as the
// heading and the rest as the preview so it reads like a titled thread.
val title: String
val preview: String
when (val event = thread.event) {
is ThreadEvent -> {
title = event.title()?.takeIf { it.isNotBlank() } ?: untitled
preview = event.content.replace('\n', ' ').trim()
}
is ForumPostEvent -> {
val body = event.body().trim()
title = body.substringBefore('\n').trim().ifEmpty { untitled }
// Everything after the first line. Using substringAfter (not removePrefix on the trimmed
// first line) avoids duplicating the first line when it had trailing spaces before the \n.
preview = body.substringAfter('\n', "").replace('\n', ' ').trim()
}
else -> {
title = untitled
preview = ""
}
}
val author = thread.author
Row(
@@ -50,15 +50,21 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@Composable
@@ -97,6 +103,12 @@ fun RelayGroupTopBar(
}
val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership
// A Buzz DM is a private 1:1 conversation: title it by the OTHER participant (not the generic "DM"
// metadata name), and drop the forum-threads + share affordances — a DM has no forum, and its
// private, membership-gated naddr is meaningless to hand out.
val isDm = channel.event?.isBuzzDm() == true
val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null
var menuOpen by remember { mutableStateOf(false) }
var showInvite by remember { mutableStateOf(false) }
var showJoinCode by remember { mutableStateOf(false) }
@@ -108,13 +120,17 @@ fun RelayGroupTopBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (dmOther != null) {
DmParticipantTitle(dmOther, channel, accountViewModel, Modifier.weight(1f, fill = false))
} else {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
RoleBadge(displayMembership)
}
Row(
@@ -154,17 +170,33 @@ fun RelayGroupTopBar(
}
},
actions = {
IconButton(onClick = {
nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url))
}) {
Icon(
symbol = MaterialSymbols.Forum,
contentDescription = stringRes(R.string.relay_group_threads_title),
modifier = Modifier.size(20.dp),
)
if (!isDm) {
IconButton(onClick = {
nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url))
}) {
Icon(
symbol = MaterialSymbols.Forum,
contentDescription = stringRes(R.string.relay_group_threads_title),
modifier = Modifier.size(20.dp),
)
}
}
val naddr = channel.toNAddr()
// Buzz workspace canvas (kind 40100): shown on any Buzz-dialect relay so a member can
// open the shared markdown doc — or create one when the channel has none yet.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
}
// remember the bech32 (naddr) encode — this top bar recomposes on every roster/metadata
// emission (observeChannel), and the encode is otherwise redone each time.
val naddr = remember(channel.groupId, isDm) { if (isDm) null else channel.toNAddr() }
if (naddr != null) {
val context = LocalContext.current
IconButton(onClick = { shareRelayGroup(context, naddr) }) {
@@ -184,7 +216,7 @@ fun RelayGroupTopBar(
)
}
!displayMembership.isMember() -> {
!displayMembership.isMember() && channel.requiresMembershipToPost() -> {
FilledTonalButton(onClick = {
// Closed groups need an invite code; open groups join directly.
if (channel.isClosed()) {
@@ -262,6 +294,25 @@ fun RelayGroupTopBar(
}
}
/** Title for a Buzz DM: the OTHER participant's display name (reactive), falling back to the channel name. */
@Composable
private fun DmParticipantTitle(
otherPubkey: HexKey,
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
modifier: Modifier,
) {
val user = remember(otherPubkey) { LocalCache.getOrCreateUser(otherPubkey) }
val name by observeUserName(user, accountViewModel)
Text(
text = name.ifBlank { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
/** Fire the system share sheet with a njump web link to the group's naddr. */
private fun shareRelayGroup(
context: Context,
@@ -112,7 +112,8 @@ class RelayGroupDiscoveryFeedFilter(
val byRelay = constraints()
val notes =
LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note ->
matches(note, byRelay)
// Non-mine path here (isMine() returned above), so the joined set is never read.
matches(note, byRelay, emptySet())
}
return sort(notes)
}
@@ -121,26 +122,36 @@ class RelayGroupDiscoveryFeedFilter(
val joined = joinedGroupIds()
val notes = HashSet<Note>()
// Groups I explicitly joined — resolve each to its metadata note (may be null until loaded).
joined.forEach { LocalCache.getRelayGroupChannelIfExists(it)?.metadataNote?.let(notes::add) }
// DMs (hidden 39000s) are excluded even from "My Groups": they belong to the DM section.
joined.forEach {
LocalCache
.getRelayGroupChannelIfExists(it)
?.takeUnless { c -> c.isHidden() }
?.metadataNote
?.let(notes::add)
}
// Plus any group whose roster lists me as an admin/member.
val me = account.userProfile().pubkeyHex
LocalCache.relayGroupChannels
.filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() }
.filter { _, channel -> channel.event != null && !channel.isHidden() && channel.membershipOf(me).isMember() }
.forEach { it.metadataNote?.let(notes::add) }
return notes
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val byRelay = constraints()
// Compute the joined set once per batch instead of rebuilding a HashSet (with per-tag URL
// normalization) inside matches() for every event.
val joined = if (isMine()) joinedGroupIds() else emptySet()
return newItems.flatMapTo(HashSet()) { note ->
when (note.event) {
is GroupMetadataEvent -> if (matches(note, byRelay)) listOf(note) else emptyList()
is GroupMetadataEvent -> if (matches(note, byRelay, joined)) listOf(note) else emptyList()
// A roster change (39001/39002) can newly qualify a group whose 39000 already
// arrived (a follow just became an admin/member). The roster note itself isn't a
// feed row, so re-test the group's metadata note and inject it if it now matches —
// otherwise the group would stay hidden (or frozen at 0 members) until a refresh.
is GroupAdminsEvent, is GroupMembersEvent ->
rosterMetadataNote(note)?.takeIf { matches(it, byRelay) }?.let { listOf(it) } ?: emptyList()
rosterMetadataNote(note)?.takeIf { matches(it, byRelay, joined) }?.let { listOf(it) } ?: emptyList()
else -> emptyList()
}
}
@@ -154,9 +165,13 @@ class RelayGroupDiscoveryFeedFilter(
private fun matches(
note: Note,
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
joined: Set<GroupId>,
): Boolean {
val channel = relayGroupDiscoveryChannelFor(note) ?: return false
if (isMine()) return isMyGroup(channel, joinedGroupIds())
// DMs carry the NIP-29 `hidden` tag (Buzz sets it on t=dm 39000s) — private conversations, not
// browsable groups. Keep them out of the discovery list entirely (they live in the DM section).
if (channel.isHidden()) return false
if (isMine()) return isMyGroup(channel, joined)
if (byRelay.isEmpty()) return false
// Only surface groups whose 39000 is actually signed by the host relay's key (NIP-29's
// authority). This drops stray user-published 39000s stored on general relays — the ones
@@ -0,0 +1,106 @@
/*
* 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.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** One screen's request to keep the viewer's Buzz DM channels' recent chat live. */
class BuzzDmJoinedChatTailQueryState(
val account: Account,
)
/**
* Always-on **live tail** for the recent chat of every Buzz DM channel the viewer belongs to — the DM
* analog of [RelayGroupJoinedChatTailFilterAssembler]. A Buzz DM is a relay-authoritative NIP-29 group
* (UUID `h`), so its messages ride the exact same `#h`-scoped batched tail; only the channel *source*
* differs — [BuzzDmChannels] (populated by
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmDiscoveryPreload]) rather than the published
* kind-10009 group list, because DM memberships are server-side and deliberately never published.
*
* Keeping these warm app-wide is what lets a Buzz DM surface on the Notifications tab and in push without
* the viewer opening the conversation. Hidden DMs (per the 30622 snapshot in [BuzzDmRegistry]) are
* excluded so a hidden conversation neither streams nor notifies.
*/
class BuzzDmJoinedChatTailFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<BuzzDmJoinedChatTailQueryState>() {
val tail = BuzzDmJoinedChatTailSubAssembler(client, ::allKeys)
val group = listOf(tail)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class BuzzDmJoinedChatTailSubAssembler(
client: INostrClient,
allKeys: () -> Set<BuzzDmJoinedChatTailQueryState>,
) : PerUniqueIdEoseManager<BuzzDmJoinedChatTailQueryState, Account>(client, allKeys) {
private val windowLoad = WindowLoadTracker("buzzDm.preview.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
override fun updateFilter(
key: BuzzDmJoinedChatTailQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val me = key.account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(me)
val channels = BuzzDmChannels.channelsFor(me).filterKeys { it !in hidden }
if (channels.isEmpty()) {
windowLoad.setExpectedRelays(emptySet())
return null
}
// Reuse the joined-group tail builder: one #h filter per host relay carrying every DM channel id
// on it, bounded by the shared recent floor (no per-channel limit, reconnect-safe).
val asTags = channels.map { (channelId, relay) -> GroupTag(channelId, relay.url) }
val filters = buildRelayGroupJoinedChatTailFilters(asTags, DmHistoryTuning.recentBoundary())
windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay })
return filters
}
override fun id(key: BuzzDmJoinedChatTailQueryState) = key.account
override fun newSub(key: BuzzDmJoinedChatTailQueryState): Subscription {
windowLoad.startLoading(key.account.scope)
return requestNewSubscription(
windowLoad.trackingListener { relay: NormalizedRelayUrl, filters -> newEose(key, relay, TimeUtils.now(), filters) },
)
}
}
@@ -25,6 +25,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,6 +66,26 @@ fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) {
KeyDataSourceSubscription(state, dataSource)
}
/**
* Always-on preview **live tail** for the viewer's Buzz DM channels' recent chat, mounted alongside
* [RelayGroupJoinedChatTailPreload] — keeps discovered DM conversations warm app-wide so a Buzz DM can
* surface on the Notifications tab and in push without opening it. Re-derives whenever a DM is discovered
* ([com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels]) or hidden/unhidden
* ([com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]).
*/
@Composable
fun BuzzDmJoinedChatTailPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val dataSource = accountViewModel.dataSources().buzzDmJoinedChatTail
val state = remember(account) { BuzzDmJoinedChatTailQueryState(account) }
val channels by BuzzDmChannels.flow.collectAsStateWithLifecycle()
val hidden by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
LaunchedEffect(channels, hidden) { dataSource.invalidateFilters() }
KeyDataSourceSubscription(state, dataSource)
}
/**
* Mount on the open group chat screen to keep the *currently open* group's recent chat live — covers a
* non-joined group opened by link (the batched preview tail is joined-only) and live updates. Lifecycle-
@@ -20,6 +20,25 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -85,8 +104,64 @@ val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
/** Timeline kinds shown in a group's chat — chat messages and polls. */
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
/**
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
* worse one — history pages fetched before the mark advance their cursors past ranges
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
*
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
* - forum posts/votes/comments (45001-45003)
* - agent jobs (43001-43006)
* - huddle lifecycle (48100-48103)
*
* Consumption for every one of these already exists in `LocalCache` (see
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
* a group feed instead of only appearing if another subscription happened to fetch them.
*/
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
listOf(
StreamMessageV2Event.KIND,
StreamMessageEditEvent.KIND,
StreamMessageDiffEvent.KIND,
SystemMessageEvent.KIND,
CanvasEvent.KIND,
ForumPostEvent.KIND,
ForumVoteEvent.KIND,
ForumCommentEvent.KIND,
JobRequestEvent.KIND,
JobAcceptedEvent.KIND,
JobProgressEvent.KIND,
JobResultEvent.KIND,
JobCancelEvent.KIND,
JobErrorEvent.KIND,
HuddleStartedEvent.KIND,
HuddleParticipantJoinedEvent.KIND,
HuddleParticipantLeftEvent.KIND,
HuddleEndedEvent.KIND,
)
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
/**
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
* and drops it). It matches nothing on a vanilla relay.
*/
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
/**
* Forum-thread kinds shown in a group's Threads tab: NIP-29 kind-11 roots + kind-1111 comments, PLUS
* Buzz forum roots (45001) + comments (45003). Requested together (a vanilla relay matches nothing on
* the Buzz kinds, a Buzz relay nothing on kind-11), so the same Threads REQ surfaces either dialect.
*/
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND, ForumPostEvent.KIND, ForumCommentEvent.KIND)
/** Content kinds a card warms ahead of a tap (chat + polls + threads + comments). */
val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
@@ -146,7 +221,7 @@ fun buildRelayGroupJoinedChatTailFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
@@ -162,7 +237,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
@@ -185,7 +260,7 @@ fun buildRelayGroupHistoryFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,
@@ -34,10 +34,12 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -68,6 +70,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
@@ -85,6 +92,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -119,6 +127,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@Stable
open class ChannelNewMessageViewModel :
@@ -156,6 +165,15 @@ open class ChannelNewMessageViewModel :
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
// When set, the composer is editing an existing Buzz stream message (kind 40002):
// the next send publishes a kind-40003 edit targeting this note instead of a new
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
val editingBuzzMessage = mutableStateOf<Note?>(null)
// Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz
// auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync).
private var pendingBuzzInviteMentions: List<User> = emptyList()
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
@@ -264,6 +282,26 @@ open class ChannelNewMessageViewModel :
draftTag.newVersion()
}
/**
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
* the next send as a kind-40003 edit of it. Editing and replying are mutually
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
* kind-40002 messages on a Buzz relay.
*/
fun editBuzzMessage(note: Note) {
replyTo.value = null
replyMode.value = ReplyMode.INLINE
editingBuzzMessage.value = note
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
draftTag.newVersion()
}
fun clearBuzzEdit() {
editingBuzzMessage.value = null
message.setTextAndPlaceCursorAtEnd("")
draftTag.newVersion()
}
open fun editFromDraft(draft: Note) {
val noteEvent = draft.event
val noteAuthor = draft.author
@@ -345,9 +383,16 @@ open class ChannelNewMessageViewModel :
}
suspend fun sendPostSync() {
// Reset before createTemplate populates it, so a prior send/draft can't leak stale mentions.
pendingBuzzInviteMentions = emptyList()
val template = createTemplate() ?: return
val channelRelays = channel?.relays() ?: emptySet()
// Buzz auto-invite runs ONLY on a real send (never draft auto-save), before the message goes
// out so the relay accepts the mention. No-op unless this is a Buzz relay group the user
// moderates; guarded so a failed add never blocks the message.
channel?.let { autoInviteMentionedBuzzMembers(it, pendingBuzzInviteMentions) }
// A geohash cell with no resolvable relays has nowhere to publish. Bail before cancel() clears
// the composer, so the user keeps their text (and draft) to retry rather than losing it silently.
if (channel is GeohashChatChannel && channelRelays.isEmpty()) return
@@ -498,7 +543,40 @@ open class ChannelNewMessageViewModel :
}
}
private suspend fun createTemplate(): EventTemplate<out Event>? {
/**
* Buzz auto-invite: mentioning someone who isn't yet a member of a Buzz workspace channel adds
* them (kind-9000 put-user) before the message goes out, so the `@`-mention resolves to a real
* member — mirroring Buzz's own composer, which is how you pull a bot into a channel by naming it.
*
* Only a moderator can issue kind-9000, so this is a no-op for a plain member (the relay would
* reject it anyway); self and already-present members are skipped. Best-effort — a failed add
* must never block the message, so each is guarded.
*/
private suspend fun autoInviteMentionedBuzzMembers(
channel: Channel,
mentioned: List<User>?,
) {
if (mentioned.isNullOrEmpty()) return
if (channel !is RelayGroupChannel || !BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) return
val me = accountViewModel.account.userProfile().pubkeyHex
if (!channel.membershipOf(me).canModerate()) return
mentioned.forEach { user ->
val pk = user.pubkeyHex
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
try {
accountViewModel.account.putRelayGroupUser(channel, pk, emptyList())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")
}
}
}
}
// `protected open` so a specialized composer (e.g. the Buzz forum reply) can reuse this whole
// rich EditFieldRow but swap only the event it builds for the composed text.
protected open suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
val messageText = message.text.toString()
@@ -511,6 +589,13 @@ open class ChannelNewMessageViewModel :
)
tagger.run()
// Stash the *explicit* @-mentions for a possible auto-invite — but only the send path acts on
// them (see sendPostSync). Exclude the reply target (seeded into pTags) so replying to a
// non-member doesn't silently add them; and NEVER invite from here, because createTemplate also
// runs on every debounced draft auto-save (sendDraftSync) — inviting here would fire kind-9000s
// while the user is still typing.
pendingBuzzInviteMentions = tagger.pTags?.filter { it != replyTo.value?.author }.orEmpty()
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
@@ -525,7 +610,12 @@ open class ChannelNewMessageViewModel :
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
// not the raw text — so replying in a thread never silently drops any of them.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
// registry — a minichat comment sent to a workspace channel would be refused
// by the relay AFTER the composer already cleared. Buzz replies always go
// through the 40002 branch with its thread markers instead.
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) {
@@ -676,6 +766,60 @@ open class ChannelNewMessageViewModel :
}
}
channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
editingBuzzMessage.value != null -> {
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
// message; content is the replacement text. Kept minimal to mirror Buzz's own
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
// re-renders with this content.
val target = editingBuzzMessage.value!!
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
}
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
// Buzz workspace message: the native kind is 40002 (stream message v2)
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
// collapsing to a single "reply" when the parent IS the root — mirrors
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
// author. The dialect check comes from BuzzRelayDialect (marked off
// verified Buzz events), not a channel subtype: channel instances are
// captured by screens for their whole life, so the dialect must be
// able to flip mid-session without swapping objects.
// Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays
// a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the
// message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither.
val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE
StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) {
replyTo.value?.let { parent ->
// The parent's root marker (when it is itself a nested reply),
// else the parent's OWN reply target (a direct reply's collapsed
// form carries the root as its "reply" marker — Buzz's relay
// validates ancestry and rejects a mis-derived root), else the
// parent starts the thread.
val parentTags = parent.event?.tags
val root =
parentTags?.buzzThreadRoot()
?: parentTags?.buzzThreadReply()
?: parent.idHex
buzzThread(root, parent.idHex)
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
}
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
channel is RelayGroupChannel -> {
// NIP-29 group message: a kind-9 chat scoped to the group with an
// `h` tag. The event is published only to the group's host relay
@@ -729,6 +873,7 @@ open class ChannelNewMessageViewModel :
message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
editingBuzzMessage.value = null
urlPreview = null
@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.FlowPreview
@@ -87,6 +98,35 @@ fun EditFieldRow(
)
}
// Buzz edit mode: a banner reminding the user the next send replaces an existing
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
channelScreenModel.editingBuzzMessage.value?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.buzz_editing_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
}
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { _ ->
ChannelFileUploadDialog(
@@ -121,9 +161,28 @@ fun EditFieldRow(
)
}
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
val lastTypingSecs = remember { longArrayOf(0L) }
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
onTextChanged = {
channelScreenModel.onMessageChanged()
// Buzz typing indicator: fire a throttled heartbeat while composing in a
// Buzz workspace channel, so other members see "… is typing". No-op on any
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
val channel = channelScreenModel.channel
if (channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
channelScreenModel.message.text.isNotEmpty()
) {
val now = TimeUtils.now()
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
lastTypingSecs[0] = now
accountViewModel.sendBuzzTyping(channel)
}
}
},
onContentReceived = { uri, mimeType ->
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},
@@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
@@ -730,6 +731,31 @@ private fun ChannelTitleWithLabelInfo(
}
}
/**
* A warning glyph on the row of a 1:1 room whose counterpart has been reported by someone the user
* follows. This is the surface where the user decides whether to open an unsolicited DM at all, so it
* warns before engagement rather than after. Group rooms render nothing.
*/
@Composable
private fun RoomReportWarningIcon(
preloadedUser: User?,
accountViewModel: AccountViewModel,
) {
val user = preloadedUser ?: return
val flow = remember(user) { accountViewModel.createUserReportWarningFlow(user) }
val state by flow.collectAsStateWithLifecycle()
if (state.shouldWarn) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = reportWarningContentDescription(state),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = StdHorzSpacer)
}
}
@Composable
private fun UserRoomCompose(
room: ChatroomKey,
@@ -752,7 +778,17 @@ private fun UserRoomCompose(
)
},
firstRow = {
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel)
val counterpartHex = room.users.singleOrNull()
if (counterpartHex != null) {
// 1:1 room: resolve the counterpart once and share it between the name and the
// report-warning icon below, instead of each doing its own LoadUser.
LoadUser(baseUserHex = counterpartHex, accountViewModel = accountViewModel) { counterpart ->
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = counterpart)
RoomReportWarningIcon(counterpart, accountViewModel)
}
} else {
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = null)
}
if (room in pinnedRooms.value) {
Icon(
symbol = MaterialSymbols.PushPin,
@@ -421,7 +421,6 @@ fun FeedLoaded(
if (liveSection != null) {
item {
DisplayLiveBubbles(liveSection, accountViewModel, nav)
Spacer(StdVertSpacer)
}
}
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
@@ -473,14 +472,21 @@ fun DisplayLiveBubbles(
) {
val feed by liveFeed.feed.collectAsStateWithLifecycle()
LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) {
itemsIndexed(feed.list, key = { _, item -> item.hashCode() }) { _, item ->
when (item) {
is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav)
is GeohashChatChannel -> RenderGeohashBubble(item, accountViewModel, nav)
is LiveActivitiesChannel -> RenderLiveActivityBubble(item, accountViewModel, nav)
// Render the row and its trailing spacer only when there is something to show.
// Emitting the spacer unconditionally reserved dead vertical space at the very
// top of the feed whenever there were no live bubbles (the common case), which
// read as an oversized gap between the top-bar divider and the first note.
if (feed.list.isNotEmpty()) {
LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) {
itemsIndexed(feed.list, key = { _, item -> item.hashCode() }) { _, item ->
when (item) {
is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav)
is GeohashChatChannel -> RenderGeohashBubble(item, accountViewModel, nav)
is LiveActivitiesChannel -> RenderLiveActivityBubble(item, accountViewModel, nav)
}
}
}
Spacer(StdVertSpacer)
}
}
@@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -373,6 +376,41 @@ class NotificationFeedFilter(
return collection.filterTo(HashSet()) { acceptableEvent(it, filterParams) }
}
/**
* The Buzz DM message [note] targets me: a kind-40002 in a `t=dm` channel whose 39000 participants
* include [me]. DM messages carry no `p` tag, so being a participant of the DM channel is the
* relevance signal — like a Marmot/Concord message. False for non-DM channels, for metadata we
* haven't loaded yet, or when I'm not a participant. (Own-message and toggle checks live at the call site.)
*/
private fun isBuzzDmForMe(
note: Note,
me: HexKey,
): Boolean {
val md = LocalCache.getRelayGroupChannelForContent(note)?.event ?: return false
return md.isBuzzDm() && md.buzzParticipants().contains(me)
}
/**
* A reaction/repost that carries NO `p` tag — so neither the follow filter nor the p-tag gate can
* route it — but whose reacted target (the LAST `e` tag, already loaded) is MY note. Buzz likes are a
* bare `["e", <id>]` with no author `p` tag (unlike NIP-25, which p-tags the reacted author), so being
* the author of the liked post is the only relevance signal. Restricted to the no-`p`-tag case so a
* well-formed reaction keeps its normal p-tag routing, and to an already-loaded target so it can't
* accept blindly.
*/
private fun isReactionToMyEvent(
note: Note,
me: HexKey,
): Boolean {
val event = note.event
if (event !is ReactionEvent && event !is RepostEvent && event !is GenericRepostEvent) return false
if (event.tags.any { it.getOrNull(0) == "p" }) return false
return note.replyTo
?.lastOrNull()
?.author
?.pubkeyHex == me
}
fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
@@ -403,6 +441,16 @@ class NotificationFeedFilter(
val noteEvent = it.event
// Buzz DM: a group chat message in a `t=dm` channel whose 39000 participants include me. A Buzz
// relay carries DM messages as either kind-9 (NIP-29 chat) or kind-40002 (stream message v2), and
// neither `p`-tags the recipient, so being a participant of the DM channel is the relevance signal
// — like a Marmot/Concord message above. Honors the same "Messages in notifications" toggle, and
// never notifies for my own message.
if ((noteEvent is StreamMessageV2Event || noteEvent is ChatEvent) && isBuzzDmForMe(it, loggedInUserHex)) {
if (!showMessages) return false
return it.author?.pubkeyHex != loggedInUserHex
}
if (!showMessages &&
(
noteEvent is ChatMessageEvent ||
@@ -475,6 +523,11 @@ class NotificationFeedFilter(
val isConcord = isConcordMessage || isConcordReaction
// A bare reaction/repost (no p-tag) to my own already-loaded note — e.g. a Buzz like, which is
// just `["e", <id>]` — is relevant to me: bypass the follow filter and satisfy the p-tag gate,
// exactly like a Concord reaction, since being the author of the liked post is the only signal.
val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex)
// Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs
// and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so
// Concord reactions do too (only isConcordMessage is gated).
@@ -493,8 +546,8 @@ class NotificationFeedFilter(
// to genuine replies, so unrelated channel chatter never leaks through.
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) &&
(isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
// For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/
@@ -20,23 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
@@ -45,6 +53,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.appendLink
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -56,16 +66,15 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -125,88 +134,121 @@ fun ZapTheDevsCard(
val releaseNoteState by observeNote(baseNote, accountViewModel)
val releaseNote = releaseNoteState.note
val heroGradient =
remember {
Brush.linearGradient(
colors = listOf(BitcoinOrange, Color(0xFF9C27B0)),
)
}
Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) {
Card(
modifier = MaterialTheme.colorScheme.imageModifier,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Title
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
Column {
// Eye-catching gradient hero header with a glowing bolt badge
Box(
modifier =
Modifier
.fillMaxWidth()
.background(heroGradient)
.padding(16.dp),
) {
Text(
text = stringRes(id = R.string.zap_the_devs_title),
style =
TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center,
) {
ZapIcon(
modifier = Modifier.size(30.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.width(14.dp))
Text(
text = stringRes(id = R.string.zap_the_devs_title),
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
}
IconButton(
modifier = Size20Modifier,
modifier = Modifier.align(Alignment.TopEnd),
onClick = { accountViewModel.markDonatedInThisVersion() },
) {
CloseIcon()
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(id = R.string.cancel),
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(modifier = StdVertSpacer)
Text(
buildAnnotatedString {
append(stringRes(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME))
append(" ")
appendLink("#value4value", MaterialTheme.colorScheme.primary) { nav.nav(Route.Hashtag("value4value")) }
},
)
Spacer(modifier = StdVertSpacer)
val noteEvent = releaseNote.event
if (noteEvent != null) {
val route =
remember(releaseNote) {
routeFor(releaseNote, accountViewModel.account)
}
if (route != null) {
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("clickable") { nav.nav(route) },
) {
append(stringRes(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-")))
}
append(" " + stringRes(id = R.string.brought_to_you_by))
},
)
} else {
Text(stringRes(id = R.string.this_version_brought_to_you_by))
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
buildAnnotatedString {
append(stringRes(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME))
append(" ")
appendLink("#value4value", MaterialTheme.colorScheme.primary) { nav.nav(Route.Hashtag("value4value")) }
},
)
Spacer(modifier = StdVertSpacer)
DisplayZapSplits(
noteEvent = noteEvent,
useAuthorIfEmpty = true,
val noteEvent = releaseNote.event
if (noteEvent != null) {
val route =
remember(releaseNote) {
routeFor(releaseNote, accountViewModel.account)
}
if (route != null) {
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("clickable") { nav.nav(route) },
) {
append(stringRes(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-")))
}
append(" " + stringRes(id = R.string.brought_to_you_by))
},
)
} else {
Text(stringRes(id = R.string.this_version_brought_to_you_by))
}
Spacer(modifier = StdVertSpacer)
DisplayZapSplits(
noteEvent = noteEvent,
useAuthorIfEmpty = true,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
ZapDonationButton(
baseNote = releaseNote,
grayTint = MaterialTheme.colorScheme.onPrimary,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
ZapDonationButton(
baseNote = releaseNote,
grayTint = MaterialTheme.colorScheme.onPrimary,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -50,7 +50,7 @@ class UserProfileReportFeedViewModel(
val followersFlow: StateFlow<List<Note>> =
user
.reports()
.receivedReportsByAuthor
.reportsNamingUser
.map {
it.values.flatten().sortedWith(sortingModel)
}.sample(500)
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -197,6 +198,11 @@ class SearchBarViewModel(
return@mapLatest Route.ConcordInvite(term)
}
// A pasted Buzz workspace invite (`…/invite/<token>`) opens the in-app join flow.
if (term.contains("/invite/") && BuzzInviteLink.parse(term) != null) {
return@mapLatest Route.BuzzInvite(term)
}
val parsed =
runCatching { Nip19Parser.uriToRoute(term)?.entity }
.onFailure { if (it is CancellationException) throw it }
@@ -124,6 +124,7 @@ val Size27dp = 27.dp
val Size30dp = 30.dp
val Size34dp = 34.dp
val Size35dp = 35.dp
val Size36dp = 36.dp
val Size40dp = 40.dp
val Size50dp = 50.dp
val Size55dp = 55.dp
@@ -2006,6 +2006,11 @@
<string name="settings_section_appearance">स्वरूप</string>
<string name="settings_section_media">ध्वनिदृश्याभिलेख तथा जानकारी</string>
<string name="settings_section_general">सामान्य</string>
<string name="settings_section_tor_engine">संयोजन</string>
<string name="settings_section_tor_routing">टोर॰ के माध्यम से</string>
<string name="settings_section_profile_sections">विभाग तथा सूचनावलियाँ</string>
<string name="settings_section_home_tabs">दृश्य पृष्ठसूचक</string>
<string name="settings_section_reminders">अनुस्मारकसूची</string>
<string name="wallet_connect">धनकोष संयोजन</string>
<string name="language">भाषा</string>
<string name="theme">प्रदर्शनशैली</string>
+105
View File
@@ -20,6 +20,10 @@
<item quantity="one">+%1$d\nreply</item>
<item quantity="other">+%1$d\nreplies</item>
</plurals>
<plurals name="dm_sender_reported">
<item quantity="one">Reported by %1$d person you follow</item>
<item quantity="other">Reported by %1$d people you follow</item>
</plurals>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
@@ -350,6 +354,14 @@
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<plurals name="buzz_workspaces_count">
<item quantity="one">%1$d workspace</item>
<item quantity="other">%1$d workspaces</item>
</plurals>
<plurals name="buzz_workspaces_channel_count">
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<plurals name="concord_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
@@ -417,6 +429,13 @@
<string name="chats_reply_not_found">Couldn\'t find this message</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Searched every relay · tap to see</string>
<string name="dm_sender_reported_who">Who reported this?</string>
<string name="dm_sender_reported_dismiss">Dismiss</string>
<string name="dm_sender_reported_hide">Hide</string>
<string name="dm_sender_reported_subtitle">Someone you follow flagged this account</string>
<string name="dm_sender_reported_icon">Reported</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="error_loading_replies">"Error loading replies: "</string>
<string name="try_again">Try again</string>
<string name="notification_feed_is_empty">No notifications yet.</string>
@@ -1278,6 +1297,7 @@
<string name="nest_create_submit">Start space</string>
<string name="nest_create_schedule_toggle">Schedule for later</string>
<string name="nest_create_when">Pick a start time</string>
<string name="agent_console_title">Agent console</string>
<string name="nests_servers_title">Nest servers</string>
<string name="nests_servers_explainer">Choose which MoQ host servers Amethyst publishes your nests to. The first entry is used by default when you start a new space.</string>
<string name="nests_servers_my_section">Your servers</string>
@@ -2187,6 +2207,7 @@
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
<string name="nests_servers_search_keywords" translatable="false">audio rooms, live, spaces, rooms</string>
<string name="agent_console_search_keywords" translatable="false">buzz, agents, personas, cost, tokens, fleet, ai, turn metrics</string>
<string name="profile_badges_search_keywords" translatable="false">badges, awards</string>
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
@@ -2567,6 +2588,9 @@
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_browse_relay_label">Relay address</string>
<string name="relay_group_browse_go">Browse</string>
<string name="relay_tor_clearnet_title">Can\'t reach this relay over Tor</string>
<string name="relay_tor_clearnet_body">%1$s isn\'t responding through Tor — its host may be blocking Tor exit nodes. Connect to it over clearnet instead?</string>
<string name="relay_tor_clearnet_action">Use clearnet</string>
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
@@ -3352,6 +3376,87 @@
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="buzz_message_edited">(edited)</string>
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_canvas_edit">Edit canvas</string>
<string name="buzz_canvas_save">Save canvas</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_edit_message">Edit</string>
<string name="buzz_editing_banner">Editing message</string>
<string name="buzz_typing_one">%1$s is typing…</string>
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
<string name="buzz_workspaces_title">Buzz Workspaces</string>
<string name="buzz_workspaces_hero_tagline">Where humans and agents build together</string>
<string name="buzz_workspaces_leave">Leave workspace</string>
<string name="buzz_workspaces_section">Your workspaces</string>
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
<string name="buzz_workspaces_browse">Browse groups</string>
<string name="buzz_console_card_title">Agent Console</string>
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
<string name="buzz_forum_new_title">New forum topic</string>
<string name="buzz_forum_body_label">What do you want to discuss?</string>
<string name="buzz_forum_post_action">Post topic</string>
<string name="buzz_forum_thread_title">Forum thread</string>
<string name="buzz_forum_no_replies">No replies yet. Be the first to respond.</string>
<string name="buzz_dm_title">Direct Messages</string>
<string name="buzz_dm_nav_label">Buzz DMs</string>
<string name="buzz_dm_card_subtitle">Private conversations on your workspaces</string>
<string name="buzz_dm_new">New message</string>
<string name="buzz_dm_empty_title">No direct messages yet</string>
<string name="buzz_dm_empty_body">Start a private conversation with anyone on a Buzz workspace.</string>
<string name="buzz_dm_more">More</string>
<string name="buzz_dm_hide">Hide conversation</string>
<string name="buzz_dm_add_member">Add member</string>
<string name="buzz_dm_add_member_title">Add someone to this DM</string>
<string name="buzz_dm_add_member_invalid">Not a valid npub or hex key</string>
<string name="buzz_dm_just_you">Just you</string>
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">To</string>
<string name="buzz_dm_add_hint">Add someone (npub or hex)</string>
<string name="buzz_dm_start">Start conversation</string>
<string name="buzz_dm_opening">Opening…</string>
<string name="buzz_dm_remove">Remove</string>
<string name="buzz_invite_title">Workspace invite</string>
<string name="buzz_invite_heading">Join this workspace</string>
<string name="buzz_invite_workspace">Workspace</string>
<string name="buzz_invite_role">Role</string>
<string name="buzz_invite_body">You\'ll open the workspace in a secure in-app browser to review its terms and finish joining. It signs you in with your Amethyst key — no password.</string>
<string name="buzz_invite_continue">Continue in browser</string>
<string name="buzz_invite_invalid">This doesn\'t look like a valid Buzz invite link.</string>
<string name="buzz_invite_expired">This invite has expired. Ask for a new one.</string>
<string name="buzz_invite_after_browser">Finish accepting the terms in the browser, then come back — your workspace will be waiting.</string>
<string name="buzz_invite_open_workspace">Open my workspaces</string>
<string name="buzz_import_title">Import workspace channels</string>
<string name="buzz_import_loading">Finding your channels…</string>
<string name="buzz_import_your_channels">Your channels on this workspace</string>
<string name="buzz_import_add">Add</string>
<string name="buzz_import_add_all">Add all</string>
<string name="buzz_import_added">Added</string>
<string name="buzz_import_empty_title">No channels found</string>
<string name="buzz_import_empty_body">You don\'t seem to be a member of any channels on this relay yet. Accept the workspace invite in the browser first, then try again.</string>
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="buzz_agent_working">Working…</string>
<string name="relay_group_add_member">Add member</string>
<string name="buzz_community_add_people">Add people to this workspace</string>
<string name="buzz_invite_create">Create invite link</string>
<string name="buzz_invite_link_title">Invite link</string>
<string name="buzz_invite_share">Share</string>
<string name="buzz_invite_copy">Copy</string>
<string name="buzz_invite_error_title">Couldn\'t create invite</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_star">Star channel</string>
<string name="buzz_unstar">Unstar channel</string>
<string name="buzz_dm_section_empty">No conversations yet</string>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">See %1$d more conversation</item>
<item quantity="other">See all %1$d conversations</item>
</plurals>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
@@ -0,0 +1,145 @@
/*
* 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.model
import android.os.Looper
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.UUID
/**
* The resolution the Notification feed relies on to surface a Buzz DM: given a bare kind-40002 message
* [Note], [LocalCache.getRelayGroupChannelForContent] finds the group channel it belongs to, and that
* channel's kind-39000 metadata answers `isBuzzDm()` + `buzzParticipants()`. This is what
* `NotificationFeedFilter.isBuzzDmForMe` composes; the per-kind acceptance is exercised at the filter.
*/
class BuzzDmNotificationResolutionTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val me = NostrSignerInternal(KeyPair())
private val other = NostrSignerInternal(KeyPair())
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper (null == null reads
// as "main"). Distinct mocks make it a worker thread. See BuzzWorkspaceChannelTest.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
BuzzRelayDialect.clearForTesting()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
BuzzRelayDialect.clearForTesting()
}
private suspend fun dmMetadata(
channelId: String,
participants: List<String>,
) = me.sign(
GroupMetadataEvent.build(channelId, name = "DM") {
add(arrayOf("t", "dm"))
participants.forEach { add(arrayOf("p", it)) }
},
)
// Attach a 40002 by consuming it (which materializes the group channel) and then set its 39000
// metadata directly — the real 39000 consume gates on Amethyst.instance.nip11Cache, which isn't
// available in a plain JVM test (see BuzzWorkspaceChannelTest). We still drive the real resolver.
private fun consumeMessageInto(
channelId: String,
metadata: GroupMetadataEvent,
message: Event,
): Note {
LocalCache.checkDeletionAndConsume(message, buzzRelay, false)
LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!!.event = metadata
return LocalCache.getNoteIfExists(message.id)!!
}
@Test
fun `a 40002 in a dm channel resolves to its channel whose metadata says dm and lists me`() =
runBlocking {
val channelId = UUID.randomUUID().toString()
val meHex = me.pubKey
val otherHex = other.pubKey
val message = other.sign(StreamMessageV2Event.build(channelId, "hey"))
val note = consumeMessageInto(channelId, dmMetadata(channelId, listOf(meHex, otherHex)), message)
val metadata = LocalCache.getRelayGroupChannelForContent(note)?.event
assertNotNull("the message resolves back to its DM channel", metadata)
assertTrue("the channel metadata is marked t=dm", metadata!!.isBuzzDm())
assertTrue("I am one of the DM participants", metadata.buzzParticipants().contains(meHex))
assertEquals(setOf(meHex, otherHex), metadata.buzzParticipants().toSet())
}
@Test
fun `a kind-9 chat message in a dm channel also resolves to its dm channel`() =
runBlocking {
// The deployed Buzz relay carries DM messages as kind-9 too, not only 40002; the resolver is
// kind-agnostic (keys off the h tag) so both surface the same DM channel.
val channelId = UUID.randomUUID().toString()
val meHex = me.pubKey
val chat = other.sign(ChatEvent.build("hey") { add(GroupIdTag.assemble(channelId)) })
val note = consumeMessageInto(channelId, dmMetadata(channelId, listOf(meHex, other.pubKey)), chat)
val metadata = LocalCache.getRelayGroupChannelForContent(note)?.event
assertNotNull("the kind-9 message resolves back to its DM channel", metadata)
assertTrue(metadata!!.isBuzzDm())
}
@Test
fun `a 40002 in a non-dm channel resolves to a channel that is not a dm`() =
runBlocking {
val channelId = UUID.randomUUID().toString()
// A plain (non-dm) channel: 39000 without the t=dm marker.
val message = other.sign(StreamMessageV2Event.build(channelId, "gm"))
val note = consumeMessageInto(channelId, me.sign(GroupMetadataEvent.build(channelId, name = "general")), message)
val metadata = LocalCache.getRelayGroupChannelForContent(note)?.event
assertNotNull(metadata)
assertFalse("a general channel is not a DM", metadata!!.isBuzzDm())
}
}
@@ -0,0 +1,193 @@
/*
* 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.model
import android.os.Looper
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.UUID
/**
* The Buzz dialect of NIP-29 in `LocalCache`: dialect detection off VERIFIED events,
* timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and
* the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id.
*/
class BuzzWorkspaceChannelTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val signer = NostrSignerInternal(KeyPair())
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
BuzzRelayDialect.clearForTesting()
BuzzWorkspaceStates.clearForTesting()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
BuzzRelayDialect.clearForTesting()
BuzzWorkspaceStates.clearForTesting()
}
private fun newChannelId() = UUID.randomUUID().toString()
private suspend fun streamMessage(
channelId: String,
text: String,
) = signer.sign(StreamMessageV2Event.build(channelId, text))
@Test
fun verifiedBuzzKindMarksDialectAndAttachesToTheSameChannel() =
runBlocking {
val channelId = newChannelId()
val msg = streamMessage(channelId, "hello workspace")
assertFalse(BuzzRelayDialect.isBuzz(buzzRelay))
LocalCache.checkDeletionAndConsume(msg, buzzRelay, false)
assertTrue("a verified 40002 marks the serving relay as Buzz", BuzzRelayDialect.isBuzz(buzzRelay))
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))
assertNotNull("the group channel exists after a 40002 lands", channel)
// The channel type never changes on dialect discovery — screens capture it for life.
assertTrue(channel is RelayGroupChannel)
assertTrue("the message is on the channel timeline", channel!!.notes.containsKey(msg.id))
}
@Test
fun channelInstanceIsStableAcrossDialectDiscovery() =
runBlocking {
val channelId = newChannelId()
val key = GroupId(channelId, buzzRelay)
// The group materializes plain (e.g. via 39000) BEFORE any Buzz kind reveals the dialect.
val before = LocalCache.getOrCreateRelayGroupChannel(key)
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "first buzz kind"), buzzRelay, false)
val after = LocalCache.getRelayGroupChannelIfExists(key)
// Same object: no swap, so every live screen/feed/composer reference stays valid.
assertTrue("dialect discovery must NOT swap the channel instance", before === after)
}
@Test
fun unverifiedEventDoesNotMarkTheDialect() =
runBlocking {
val channelId = newChannelId()
// Tamper the signature so verification fails.
val real = streamMessage(channelId, "spoof")
val forged = StreamMessageV2Event(real.id, real.pubKey, real.createdAt, real.tags, real.content, "00".repeat(64))
LocalCache.checkDeletionAndConsume(forged, buzzRelay, false)
assertFalse("an unverifiable event must not flip the dialect", BuzzRelayDialect.isBuzz(buzzRelay))
}
@Test
fun editOverlayTracksNewestEditAndIsNotATimelineRow() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "teh typo")
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
val edit1 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the typo", createdAt = original.createdAt + 10),
)
val edit2 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 20),
)
// Out-of-order arrival: newer first, older second.
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id))
assertEquals(edit2.id, state.editFor(original.id)?.idHex)
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!!
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id))
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit2.id))
}
@Test
fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() =
runBlocking {
// An edit consumed with a null provenance relay (own optimistic send) must
// still record its overlay — the registry is keyed by channel id, not relay.
val channelId = newChannelId()
val original = streamMessage(channelId, "original")
LocalCache.checkDeletionAndConsume(original, null, true)
val edit =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "edited offline", createdAt = original.createdAt + 5),
)
LocalCache.checkDeletionAndConsume(edit, null, true)
assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id))
}
@Test
fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "will be pruned")
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5))
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertNotNull(state.editFor(original.id))
// Simulate the message having been reaped from the channel.
state.pruneEdits(emptySet())
assertNull("overlay for a pruned message must be dropped", state.editFor(original.id))
}
}
@@ -0,0 +1,122 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `LocalCache` is a process-wide object (`LocalCache.kt:353`) and JUnit 4's default method order is
* hash-based, not source order. So each test method uses its **own** reporter, target and event ids —
* sharing them across methods would let one test's consumed report inflate another's count depending
* on which ran first.
*/
class ReportNamingIndexIngestionTest {
private fun reportNamingBothNoteAndAuthor(
id: String,
reporter: String,
target: String,
reportedNoteId: String,
) = ReportEvent(
id = id,
pubKey = reporter,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("e", reportedNoteId, ReportType.IMPERSONATION.code),
arrayOf("p", target, ReportType.IMPERSONATION.code),
),
content = "",
sig = "sig",
)
@Test
fun aReportFiledFromANoteStillReachesTheUserNamingIndex() {
val reporter = "c1".repeat(32)
val target = "c2".repeat(32)
LocalCache.consume(
reportNamingBothNoteAndAuthor("c3".repeat(32), reporter, target, "c4".repeat(32)),
null,
true,
)
val reported = LocalCache.getUserIfExists(target)
assertTrue("the reported author must exist in the cache", reported != null)
assertEquals(1, reported!!.reports().reportsNaming(setOf(reporter)).size)
}
@Test
fun theHideThresholdCountIsUnaffectedByNoteFiledReports() {
val reporter = "d1".repeat(32)
val target = "d2".repeat(32)
LocalCache.consume(
reportNamingBothNoteAndAuthor("d3".repeat(32), reporter, target, "d4".repeat(32)),
null,
true,
)
val reported = LocalCache.getUserIfExists(target)!!
assertEquals(1, reported.reports().reportsNaming(setOf(reporter)).size)
assertEquals(0, reported.reports().countReportAuthorsBy(setOf(reporter)))
}
@Test
fun aBareCoMentionedPTagIsNotIndexedWhileTheExplicitlyTypedOffenderIs() {
val reporter = "e1".repeat(32)
val offender = "e2".repeat(32)
val bystander = "e3".repeat(32)
val reportedNoteId = "e4".repeat(32)
val report =
ReportEvent(
id = "e5".repeat(32),
pubKey = reporter,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("e", reportedNoteId, ReportType.IMPERSONATION.code),
// offender: explicitly typed, own report type
arrayOf("p", offender, ReportType.IMPERSONATION.code),
// bystander: bare 2-element p tag, no explicit type of its own
arrayOf("p", bystander),
),
content = "",
sig = "sig",
)
LocalCache.consume(report, null, true)
val reportedOffender = LocalCache.getUserIfExists(offender)
assertTrue("the offender must exist in the cache", reportedOffender != null)
assertEquals(1, reportedOffender!!.reports().reportsNaming(setOf(reporter)).size)
val reportedBystander = LocalCache.getUserIfExists(bystander)
assertTrue("the bystander must exist in the cache", reportedBystander != null)
assertEquals(0, reportedBystander!!.reports().reportsNaming(setOf(reporter)).size)
}
}
@@ -0,0 +1,59 @@
/*
* 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.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Every relay-group timeline REQ carries the Buzz stream kinds unconditionally (they
* simply match nothing on a vanilla relay); gating them created a history-cursor bug.
*/
class BuzzTimelineKindsTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
@Test
fun theUnifiedTimelineKindSetContainsBothNip29AndBuzz() {
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(RELAY_GROUP_TIMELINE_KINDS))
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS))
}
@Test
fun everyTimelineReqCarriesBuzzKindsUnconditionally() {
// The Buzz kinds are requested on EVERY relay-group timeline REQ (open tail,
// joined fleet tail), independent of any dialect mark. Gating them created a
// history-cursor-skip bug — this pins the unconditional contract.
val openTail = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
assertTrue(openTail.filter.kinds!!.contains(StreamMessageV2Event.KIND))
val fleet =
buildRelayGroupJoinedChatTailFilters(
listOf(GroupTag("g1", buzzRelay.url), GroupTag("g2", vanillaRelay.url)),
sinceEpoch = 0L,
)
fleet.forEach { assertTrue(it.filter.kinds!!.contains(StreamMessageV2Event.KIND)) }
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -60,7 +62,10 @@ class RelayGroupFilterBuildersTest {
private val g1OnA = GroupId("g1", relayA)
private val timelineKinds = listOf(ChatEvent.KIND, PollEvent.KIND)
private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND)
// NIP-29 kind-11/1111 threads + Buzz forum roots (45001) & comments (45003) — the Threads REQ
// fetches both dialects (a vanilla relay matches nothing on the Buzz kinds and vice versa).
private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND, ForumPostEvent.KIND, ForumCommentEvent.KIND)
// --- State (always-on): one #d filter per host relay, batching that relay's group ids ---
@@ -113,7 +118,9 @@ class RelayGroupFilterBuildersTest {
assertEquals(2, filters.size)
val a = filters.single { it.relay == relayA }
assertEquals(timelineKinds, a.filter.kinds)
// The joined tail now always carries the Buzz timeline kinds too (see
// RELAY_GROUP_ALL_TIMELINE_KINDS / BuzzTimelineKindsTest).
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, a.filter.kinds)
assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet())
assertEquals(999L, a.filter.since)
assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit)
@@ -126,7 +133,11 @@ class RelayGroupFilterBuildersTest {
fun `open chat tail is a single host h-filter with since only`() {
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
assertEquals(relayA, f.relay)
assertEquals(timelineKinds, f.filter.kinds)
// The open-channel tail carries the full Buzz timeline set PLUS the ephemeral
// kind-20002 typing indicator (RELAY_GROUP_OPEN_TAIL_KINDS) — typing is scoped to
// the one channel on screen, not the joined fleet. It is also the Buzz-dialect
// detection bootstrap. Harmless on vanilla relays.
assertEquals(RELAY_GROUP_OPEN_TAIL_KINDS, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
assertEquals(5L, f.filter.since)
assertNull(f.filter.until)
+20
View File
@@ -591,6 +591,26 @@ screen speaks.
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
### Buzz workspaces (block/buzz — NIP-29 dialect)
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
and `commons` aggregator the app uses.
| Command | What it does |
| --- | --- |
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
| `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). |
| `amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]` | List my DMs: discover channels I'm in via kind:44100 member-added notifications (`#p`=me), keep the ones a kind:40099 `dm_created` marks as DMs. (The deployed relay does not emit kind:41001.) |
| `amy buzz dm open RELAY PUBKEY [PUBKEY…]` | Open (or re-surface) a DM with 1-8 people (kind:41010). The relay assigns the channel id and confirms via 41001. |
| `amy buzz dm hide RELAY CHANNEL` | Hide a DM from my sidebar (kind:41012); re-opening it un-hides. |
| `amy buzz dm add-member RELAY CHANNEL PUBKEY` | Add a member to an existing group DM (kind:41011). |
### Concord Channels (encrypted communities)
Encrypted, serverless communities (the CORD specs). Community secrets
+1
View File
@@ -59,6 +59,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-18 reposts / quotes | 🆕 | |
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
| NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. |
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
@@ -523,7 +523,30 @@ class Context(
// event in the local cache.
verifyAndStore(event)
if (relayList.isEmpty()) return emptyMap()
return client.publishAndCollectResults(event, relayList, timeoutSecs)
var results = client.publishAndCollectResults(event, relayList, timeoutSecs)
// NIP-42 write path: an auth-required relay rejects the FIRST publish with
// `auth-required`. The publish opens a connection, races the async AUTH reply, and
// tears down before it lands — and [relayAuth] only re-auths on a REQ `CLOSED`, never
// on a rejected EVENT `OK`. The READ path, though, holds the connection open and its
// `auth-required:` CLOSED reliably drives a re-auth (`reauthenticateIfAuthRequired`),
// leaving the pooled connection authenticated. So on `auth-required` we warm auth with
// a tiny `pendingOnAuthRequired` REQ, then retry the publish on the now-authed socket.
var attempt = 0
while (attempt < 4) {
val needAuth =
results
.filterValues { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }
.keys
if (needAuth.isEmpty()) break
// A cheap REQ whose only purpose is to force the AUTH handshake to completion.
val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1))
drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true)
results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs)
attempt++
}
return results
}
/**
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
import com.vitorpamplona.amethyst.cli.commands.CountCommand
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
@@ -285,6 +286,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"relay" -> RelayCommands.dispatch(dataDir, tail)
"marmot" -> marmotDispatch(dataDir, tail)
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
"buzz" -> BuzzCommands.dispatch(dataDir, tail)
"dm" -> DmCommands.dispatch(dataDir, tail)
"geochat" -> GeochatCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
@@ -802,6 +804,13 @@ private fun printUsage() {
| [--role admin|moderator]
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
| buzz post RELAY GID <text> post a kind-40002 stream message
| buzz read RELAY GID [--limit N] read recent workspace messages
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
| buzz personas [--relays R,R] list my kind-30175 personas
|
|Marmot (MLS group messaging):
| marmot key-package publish publish a fresh KeyPackage
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
@@ -0,0 +1,606 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
* the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA),
* and the agent console (turn-metric aggregation + personas).
*/
object BuzzCommands {
private val USAGE =
"""
|amy buzz join <invite-url> [--accept-policy] redeem a Buzz invite link (HTTP claim, NIP-98)
|amy buzz post RELAY GID <text> post a kind-40002 stream message
|amy buzz read RELAY GID [--limit N] read recent workspace messages (9/40002/40099)
| [--timeout SECS]
|amy buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline; prints the auth tag)
| [--after UNIX] [--before UNIX]
|amy buzz console [--relays R,R] decrypt + aggregate my kind-44200 turn metrics
| [--timeout SECS]
|amy buzz personas [--relays R,R] list my kind-30175 personas
| [--timeout SECS]
|amy buzz dm list [--relays R,R] list my DMs (44100 member-added + 40099 dm_created)
| [--limit N] [--timeout SECS]
|amy buzz dm open RELAY PUBKEY [PUBKEY…] open a DM with 1-8 people (kind-41010)
|amy buzz dm hide RELAY CHANNEL hide a DM from my sidebar (kind-41012)
|amy buzz dm add-member RELAY CHANNEL PUBKEY add a member to a group DM (kind-41011)
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"buzz",
tail,
USAGE,
mapOf(
"join" to { rest -> join(dataDir, rest) },
"post" to { rest -> post(dataDir, rest) },
"read" to { rest -> read(dataDir, rest) },
"attest" to { rest -> attest(dataDir, rest) },
"console" to { rest -> console(dataDir, rest) },
"personas" to { rest -> personas(dataDir, rest) },
"dm" to { rest -> dm(dataDir, rest) },
),
)
/** `buzz dm …` — the Buzz direct-message sub-verbs (list / open / hide / add-member). */
private suspend fun dm(
dataDir: DataDir,
tail: Array<String>,
): Int {
val usage =
"""
|amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]
|amy buzz dm open RELAY PUBKEY [PUBKEY…]
|amy buzz dm hide RELAY CHANNEL
|amy buzz dm add-member RELAY CHANNEL PUBKEY
""".trimMargin()
return route(
"buzz dm",
tail,
usage,
mapOf(
"list" to { rest -> dmList(dataDir, rest) },
"open" to { rest -> dmOpen(dataDir, rest) },
"hide" to { rest -> dmHide(dataDir, rest) },
"add-member" to { rest -> dmAddMember(dataDir, rest) },
),
)
}
/** `buzz dm list` → discovers my DM channels via kind-44100 member-added notifications, filtered to the kind-40099 `dm_created` ones. */
private suspend fun dmList(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val limit = args.flag("limit")?.toIntOrNull() ?: 50
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
args.rejectUnknown("relays", "limit", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
// The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's
// channel id synchronously in the open OK, and (b) addresses each member a kind-44100
// member-added notification (`#p` = me, `h` = channel). So discover the channels I'm in
// via 44100, then keep those whose kind-40099 system message marks them a DM.
val memberFilter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me)))
val channelIds =
ctx
.drain(relays.associateWith { listOf(memberFilter) }, timeoutSecs * 1000, pendingOnAuthRequired = true)
.map { it.second }
.filterIsInstance<MemberAddedNotificationEvent>()
.mapNotNull { it.channel() }
.distinct()
if (channelIds.isEmpty()) {
Output.emit(mapOf("count" to 0, "dms" to emptyList<Any>()))
return 0
}
val sysFilter = Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds))
val dms =
ctx
.drain(relays.associateWith { listOf(sysFilter) }, timeoutSecs * 1000, pendingOnAuthRequired = true)
.map { it.second }
.filterIsInstance<SystemMessageEvent>()
.filter { it.payload()?.type == "dm_created" }
.distinctBy { it.channel() }
.sortedByDescending { it.createdAt }
.take(limit)
.map { sys ->
// The relay's dm_created content carries a `participants` array our
// SystemMessagePayload model drops; read it from the raw content.
val participants =
runCatching {
jsonParser
.parseToJsonElement(sys.content)
.jsonObject["participants"]
?.let { arr ->
arr
.toString()
.trim('[', ']')
.split(",")
.map { it.trim().trim('"') }
.filter { it.isNotBlank() }
}
}.getOrNull().orEmpty()
mapOf(
"dm_id" to sys.channel(),
"participants" to participants,
"created_at" to sys.createdAt,
)
}
Output.emit(mapOf("count" to dms.size, "dms" to dms))
return 0
}
}
/** `buzz dm open RELAY PUBKEY [PUBKEY…]` → publishes a kind-41010 with 1-8 `p` participants. */
private suspend fun dmOpen(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz dm open RELAY PUBKEY [PUBKEY…]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val participants = mutableListOf<HexKey>()
var i = 1
while (true) {
val raw = args.positionalOrNull(i) ?: break
val hex =
decodePublicKeyAsHexOrNull(raw.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $raw")
if (hex !in participants) participants.add(hex)
i++
}
if (participants.size !in DmOpenEvent.MIN_PARTICIPANTS..DmOpenEvent.MAX_PARTICIPANTS) {
return Output.error("bad_args", "a DM needs ${DmOpenEvent.MIN_PARTICIPANTS}-${DmOpenEvent.MAX_PARTICIPANTS} participants")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val signed = ctx.signer.sign(DmOpenEvent.build(participants))
val ack = ctx.publish(signed, setOf(relay))
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
// The relay confirms a DM open synchronously in the OK message as
// `response:{"channel_id":"…","created":bool}` — the authoritative, relay-assigned
// channel UUID. Surface it so callers open the chat straight from the ack.
val okMessage = ack.values.firstOrNull { it.accepted }?.message
val channelId =
okMessage
?.substringAfter("response:", "")
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
jsonParser
.parseToJsonElement(it)
.jsonObject["channel_id"]
?.jsonPrimitive
?.content
}.getOrNull()
}
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"relay" to relay.url,
"participants" to participants,
"published" to ack.values.any { it.accepted },
"channel_id" to channelId,
"relay_message" to okMessage,
),
)
return 0
}
}
/** `buzz dm hide RELAY CHANNEL` → publishes a kind-41012 scoped by the DM's `h` tag. */
private suspend fun dmHide(
dataDir: DataDir,
rest: Array<String>,
): Int =
publishScoped(dataDir, rest, "buzz dm hide RELAY CHANNEL") { _, channelId, _ ->
DmHideEvent.build(channelId)
}
/** `buzz dm add-member RELAY CHANNEL PUBKEY` → publishes a kind-41011 (`h` + new `p`). */
private suspend fun dmAddMember(
dataDir: DataDir,
rest: Array<String>,
): Int {
val usage = "buzz dm add-member RELAY CHANNEL PUBKEY"
val memberInput = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
val member =
decodePublicKeyAsHexOrNull(memberInput.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $memberInput")
return publishScoped(dataDir, rest, usage) { _, channelId, _ ->
DmAddMemberEvent.build(channelId, member)
}
}
private val jsonMedia = "application/json".toMediaType()
private val jsonParser = Json { ignoreUnknownKeys = true }
/**
* `buzz join <invite-url> [--accept-policy]` → redeems a Buzz workspace invite. A Buzz
* invite is NOT a NIP-29 code; it's a relay-signed token claimed over HTTP against the
* tenant host: optionally accept the join policy, then `POST /api/invites/claim`
* NIP-98-signed by our key. On success we become a relay member and can REQ/publish.
*/
private suspend fun join(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz join <invite-url> [--accept-policy]"
val urlArg = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val acceptPolicy = args.bool("accept-policy")
args.rejectUnknown("accept-policy")
val invite = BuzzInviteLink.parse(urlArg) ?: return Output.error("bad_args", "not a Buzz invite link: $urlArg")
if (invite.isExpired(TimeUtils.now())) return Output.error("expired", "this invite has expired")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val http = ctx.okhttp
// 1. Join policy (optional). A relay with a configured policy requires an accepted
// receipt before the claim; one without returns 404 and we skip straight to claim.
var policyReceipt: String? = null
val (policyCode, policyBody) = httpGet(http, "${invite.httpBase()}/api/join-policy")
if (policyCode == 200) {
val policy = jsonParser.parseToJsonElement(policyBody).jsonObject["policy"]?.jsonObject
val version = policy?.get("version")?.jsonPrimitive?.content
if (version != null) {
if (!acceptPolicy) {
return Output.error(
"policy_required",
"this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent",
)
}
// 2. Accept policy → short-lived, invite-bound receipt (no NIP-98 auth).
val acceptReq =
buildJsonObject {
put("code", invite.code)
put("policy_version", version)
put("age_confirmed", true)
}.toString()
val (acceptCode, acceptBody) = httpPost(http, "${invite.httpBase()}/api/invites/accept-policy", acceptReq, null)
if (acceptCode != 200) return Output.error("policy_failed", "accept-policy failed ($acceptCode): $acceptBody")
policyReceipt =
jsonParser
.parseToJsonElement(acceptBody)
.jsonObject["receipt"]
?.jsonPrimitive
?.content
?: return Output.error("policy_failed", "accept-policy returned no receipt: $acceptBody")
}
}
// 3. Claim — NIP-98-signed POST. The `u` tag must equal the tenant-host URL.
val claimUrl = "${invite.httpBase()}/api/invites/claim"
val claimReq =
buildJsonObject {
put("code", invite.code)
policyReceipt?.let { put("policy_receipt", it) }
}.toString()
val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray()))
val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken())
if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody")
val result = jsonParser.parseToJsonElement(claimBody).jsonObject
Output.emit(
mapOf(
"status" to result["status"]?.jsonPrimitive?.content,
"community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId),
"role" to (result["role"]?.jsonPrimitive?.content ?: invite.role),
"host" to invite.host,
"relay" to invite.relayUrl(),
),
)
return 0
}
}
private suspend fun httpGet(
http: OkHttpClient,
url: String,
): Pair<Int, String> =
withContext(Dispatchers.IO) {
http
.newCall(
Request
.Builder()
.url(url)
.get()
.build(),
).execute()
.use { it.code to (it.body?.string() ?: "") }
}
private suspend fun httpPost(
http: OkHttpClient,
url: String,
body: String,
auth: String?,
): Pair<Int, String> =
withContext(Dispatchers.IO) {
val builder = Request.Builder().url(url).post(body.toRequestBody(jsonMedia))
if (auth != null) builder.header("Authorization", auth)
http.newCall(builder.build()).execute().use { it.code to (it.body?.string() ?: "") }
}
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
private suspend fun post(
dataDir: DataDir,
rest: Array<String>,
): Int {
val usage = "buzz post RELAY GID <text>"
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
StreamMessageV2Event.build(groupId, text)
}
}
/** `buzz read RELAY GID [--limit N] [--timeout SECS]` → drains the recent human-visible timeline. */
private suspend fun read(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz read RELAY GID [--limit N] [--timeout SECS]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val limit = args.flag("limit")?.toIntOrNull() ?: 50
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
args.rejectUnknown("limit", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val filter =
Filter(
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND, SystemMessageEvent.KIND),
tags = mapOf("h" to listOf(groupId)),
limit = limit,
)
val messages =
ctx
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000, pendingOnAuthRequired = true)
.map { it.second }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.map {
mapOf(
"id" to it.id,
"kind" to it.kind,
"author" to it.pubKey,
"created_at" to it.createdAt,
"content" to it.content,
)
}
Output.emit(mapOf("group_id" to groupId, "relay" to relay.url, "count" to messages.size, "messages" to messages))
return 0
}
}
/**
* `buzz attest AGENT [--kind K] [--after T] [--before T]` → signs a NIP-OA
* [OwnerAttestation] authorizing AGENT and prints the `auth` tag. Offline (the
* signature covers a hashed commitment, not an event) and needs a local private key.
*/
private suspend fun attest(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]"
val agentInput = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val agentHex =
decodePublicKeyAsHexOrNull(agentInput.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid agent public key (npub or 64-char hex): $agentInput")
val kind = args.flag("kind")?.let { it.toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer") }
val after = args.flag("after")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "after must be a unix time") }
val before = args.flag("before")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "before must be a unix time") }
args.rejectUnknown("kind", "after", "before")
Context.open(dataDir).use { ctx ->
val keyPair = ctx.identity.keyPair()
if (keyPair.privKey == null) return Output.error("no_private_key", "attestation signing needs a local private key")
val conditions = AttestationConditions(kind = kind, createdAtBefore = before, createdAtAfter = after)
val attestation =
try {
OwnerAttestation.sign(agentHex, conditions, keyPair)
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "could not sign the attestation")
}
Output.emit(
mapOf(
"agent" to agentHex,
"owner" to attestation.ownerPubKey,
"conditions" to attestation.conditions.ifEmpty { null },
"sig" to attestation.sig,
"auth_tag" to attestation.toTag().toList(),
),
)
return 0
}
}
/**
* `buzz console [--relays R,R] [--timeout SECS]` → fetches my kind-44200 turn metrics
* (`#p` = me), NIP-44-decrypts them, and aggregates fleet + per-agent cost via the same
* [AgentFleetAggregator] the app's Agent Console uses.
*/
private suspend fun console(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
args.rejectUnknown("relays", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
val decrypted =
ctx
.drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000, pendingOnAuthRequired = true)
.map { it.second }
.filterIsInstance<AgentTurnMetricEvent>()
.distinctBy { it.id }
.mapNotNull { e -> e.decryptOrNull(ctx.signer)?.let { (e.agentPubKey() ?: e.pubKey) to it } }
val metrics = AgentFleetAggregator.aggregate(decrypted)
Output.emit(
mapOf(
"total_cost_usd" to metrics.totals.costUsd,
"total_tokens" to metrics.totals.totalTokens,
"input_tokens" to metrics.totals.inputTokens,
"output_tokens" to metrics.totals.outputTokens,
"turns" to metrics.totalTurns,
"sessions" to metrics.totalSessions,
"agents" to metrics.agents.size,
"estimated" to metrics.hasUnreliableEstimates,
"breakdown" to
metrics.agents.map {
mapOf(
"agent" to it.agentPubKey,
"cost_usd" to it.totals.costUsd,
"tokens" to it.totals.totalTokens,
"turns" to it.turns,
"sessions" to it.sessions,
"models" to it.models.sorted(),
"last_activity" to it.lastActivity,
)
},
),
)
return 0
}
}
/** `buzz personas [--relays R,R] [--timeout SECS]` → lists my kind-30175 persona definitions. */
private suspend fun personas(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
args.rejectUnknown("relays", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
val personas =
ctx
.drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000, pendingOnAuthRequired = true)
.map { it.second }
.filterIsInstance<PersonaEvent>()
// Newest per addressable slug (replaceable): keep the latest for each d tag.
.groupBy { it.slug() }
.values
.mapNotNull { versions -> versions.maxByOrNull { it.createdAt } }
.sortedBy { it.personaOrNull()?.displayName ?: it.slug() ?: "" }
.map {
val content = it.personaOrNull()
mapOf(
"slug" to it.slug(),
"display_name" to content?.displayName,
"model" to content?.model,
"runtime" to content?.runtime,
"provider" to content?.provider,
)
}
Output.emit(mapOf("count" to personas.size, "personas" to personas))
return 0
}
}
/** The `--relays` set if given, else the account's outbox relays. */
private suspend fun relaysFor(
ctx: Context,
relaysFlag: String?,
) = relaysFlag
?.split(",")
?.mapNotNull { normalizeGroupRelay(it) }
?.toSet()
?: ctx.outboxRelays()
}
@@ -0,0 +1,68 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Live "an agent is currently working" state for Buzz workspaces, fed by the ephemeral NIP-AO
* observer frames (kind-24200, `#p` = owner). Frames stream in only while an agent is actively
* running and only reach the owner, so the mere arrival of a recent frame — no decryption needed —
* is the liveness signal a "Working…" indicator wants.
*
* Shape: `agentPubKey -> the newest frame time (secs)`. The display edge decides how fresh counts
* as "working" (a short window), since ephemeral frames are frequent while active and simply stop
* when the turn ends. Process-wide singleton like [BuzzTypingState] / [BuzzPresenceState]; mutations
* are lock-guarded because relay reads land on several reader threads.
*/
object BuzzAgentActivityState {
private val lock = KmpLock()
private val mutableActivity = MutableStateFlow<PersistentMap<HexKey, Long>>(persistentMapOf())
/** `agentPubKey -> newest observer-frame time (secs)`; the UI collects this and checks freshness. */
val flow: StateFlow<Map<HexKey, Long>> = mutableActivity
/**
* Records that [agent] emitted a frame at [atSecs]; keeps the newest so staleness is monotonic.
* Backed by a persistent map so a record shares structure with the previous snapshot instead of
* copying the whole map — this runs on a hot relay-consume path.
*/
fun record(
agent: HexKey,
atSecs: Long,
) = lock.withLock {
val prev = mutableActivity.value[agent]
if (prev != null && atSecs <= prev) return@withLock
mutableActivity.value = mutableActivity.value.put(agent, atSecs)
}
/** Test-only: clears all activity so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
mutableActivity.value = persistentMapOf()
}
}
@@ -0,0 +1,60 @@
/*
* 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.commons.model.buzz
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The set of Buzz workspace channels the user has **starred** (favorited), keyed by channel id (the
* NIP-29 `h`/UUID, globally unique on Buzz). Starred channels float to the top of the community view.
*
* There is no Nostr event for a personal star — it's the client's own bookkeeping — so, like
* [BuzzWorkspaces], this is a process-wide singleton mirrored to a device-global store by the
* platform ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
*/
object BuzzChannelStars {
private val starred = MutableStateFlow<Set<String>>(emptySet())
/** The starred channel ids; the community view collects this to pin + badge them. */
val flow: StateFlow<Set<String>> = starred
fun isStarred(channelId: String): Boolean = channelId in starred.value
/** Flips [channelId]'s star. Returns the new state (true = now starred). */
fun toggle(channelId: String): Boolean {
while (true) {
val current = starred.value
val next = if (channelId in current) current - channelId else current + channelId
if (starred.compareAndSet(current, next)) return channelId in next
}
}
/** Replaces the whole set — used to restore from disk at startup. */
fun restore(ids: Set<String>) {
starred.value = ids
}
/** Test-only: clears the set so unit tests don't leak state into each other. */
fun clearForTesting() {
starred.value = emptySet()
}
}
@@ -0,0 +1,108 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Process-wide registry of **Buzz community (relay-wide) membership**, from the NIP-43 relay-member
* events a Buzz relay signs: the full roster snapshot ([updateSnapshot], kind 13534) and the
* incremental add/remove deltas ([applyDelta], kinds 8000/8001).
*
* Buzz tracks membership at two levels: a per-channel NIP-29 roster (39001/39002) AND this
* community-wide list. A community member can read and post across the community's channels without
* appearing in any per-channel roster, so [RelayGroupChannel.membershipOf] consults this as a
* fallback — otherwise a community member (e.g. an admin) reads as `NONE` and is wrongly gated.
*
* Roles are intentionally dropped: Quartz's `MemberTag`/`PTag` parsers expose only the pubkey, and
* for the participation gate plain membership is enough — mapping a community member to `MEMBER`
* (never channel `ADMIN`) avoids over-granting per-channel moderation.
*
* Ordering is last-writer-wins on `created_at`: the newest snapshot-or-delta a relay has emitted
* wins, and older out-of-order arrivals are ignored. Buzz republishes the 13534 snapshot after every
* membership change, so the snapshot is normally the freshest event and is authoritative.
*
* Like `LocalCache`/`BuzzRelayDialect`, this is one copy per process (populated only in the main app
* process). Marks are scoped per relay (community) since a member may belong to several.
*/
object BuzzCommunityMembership {
private val lock = KmpLock()
private class Roster {
val members = HashSet<HexKey>()
/** `created_at` of the last snapshot-or-delta applied; guards against out-of-order arrivals. */
var version = 0L
}
private val rosters = HashMap<NormalizedRelayUrl, Roster>()
/** Whether [pubkey] is a community member of [relay] per the relay-signed NIP-43 roster. */
fun isMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
): Boolean = lock.withLock { rosters[relay]?.members?.contains(pubkey) == true }
/**
* Apply a full roster snapshot (kind 13534). Replaces the relay's member set when [createdAt] is at
* least as new as the last applied event. Returns true when the effective set changed.
*/
fun updateSnapshot(
relay: NormalizedRelayUrl,
members: Set<HexKey>,
createdAt: Long,
): Boolean =
lock.withLock {
val roster = rosters.getOrPut(relay) { Roster() }
if (createdAt < roster.version) return false
val changed = roster.members != members
roster.members.clear()
roster.members.addAll(members)
roster.version = createdAt
changed
}
/**
* Apply an incremental delta (kind 8000 add / 8001 remove) on top of the current set, when
* [createdAt] is at least as new as the last applied event. Returns true when the set changed.
*/
fun applyDelta(
relay: NormalizedRelayUrl,
add: Set<HexKey> = emptySet(),
remove: Set<HexKey> = emptySet(),
createdAt: Long,
): Boolean =
lock.withLock {
val roster = rosters.getOrPut(relay) { Roster() }
if (createdAt < roster.version) return false
var changed = false
if (add.isNotEmpty()) changed = roster.members.addAll(add) || changed
if (remove.isNotEmpty()) changed = roster.members.removeAll(remove) || changed
roster.version = createdAt
changed
}
/** Test-only: clears all rosters so unit tests don't leak state into each other. */
fun clearForTesting() = lock.withLock { rosters.clear() }
}
@@ -0,0 +1,82 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* App-wide, per-viewer set of the Buzz **DM channels** the viewer belongs to — each a relay-generated
* UUID (`channelId`) plus the relay that vouched for it.
*
* The deployed relay does not emit a queryable kind-41001; it enumerates a member's channels by
* addressing each one a kind-44100 member-added notification (`#p` = me). The always-on
* `BuzzDmDiscovery` subscribes that `#p=me` stream across the joined Buzz relays and records the
* result here, so the always-on DM chat tail can keep those channels' messages warm in `LocalCache`
* app-wide (which is what lets a Buzz DM surface on the Notifications tab and in push without the
* viewer opening the conversation first).
*
* Kept **per-viewer** because the 44100 stream is `#p`-gated to its owner and the process can switch
* accounts. Mutations are lock-guarded because discovery runs across several relay reader threads.
* Like [BuzzDmRegistry] / [BuzzWorkspaces], a process-wide singleton.
*/
object BuzzDmChannels {
private val lock = KmpLock()
private val byViewer = HashMap<HexKey, MutableMap<String, NormalizedRelayUrl>>()
private val mutableFlow = MutableStateFlow<Map<HexKey, Map<String, NormalizedRelayUrl>>>(emptyMap())
/** Per-viewer discovered DM channels (`channelId` -> the relay it was discovered on). */
val flow: StateFlow<Map<HexKey, Map<String, NormalizedRelayUrl>>> = mutableFlow
/**
* Records that [viewer] belongs to DM channel [channelId] on [relay]. Returns true when this is a
* newly seen (viewer, channel) pair so callers can trigger a re-subscribe; a repeat that only
* re-confirms the same relay returns false and does not churn the flow.
*/
fun record(
viewer: HexKey,
channelId: String,
relay: NormalizedRelayUrl,
): Boolean =
lock.withLock {
val channels = byViewer.getOrPut(viewer) { mutableMapOf() }
if (channels[channelId] == relay) return@withLock false
channels[channelId] = relay
mutableFlow.value = snapshot()
true
}
/** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */
fun channelsFor(viewer: HexKey): Map<String, NormalizedRelayUrl> = mutableFlow.value[viewer] ?: emptyMap()
private fun snapshot(): Map<HexKey, Map<String, NormalizedRelayUrl>> = byViewer.mapValues { it.value.toMap() }
/** Test-only: clears all registry state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
byViewer.clear()
mutableFlow.value = emptyMap()
}
}
@@ -0,0 +1,74 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Per-viewer set of **hidden** Buzz DM channels, fed by the relay-signed per-viewer DM Visibility
* snapshot ([DmVisibilityEvent], `kind:30622`) as `LocalCache` consumes it.
*
* DM *discovery* itself doesn't live here: the deployed relay enumerates a member's channels via
* kind-44100 member-added notifications and marks DMs with the `t` tag on their kind-39000
* metadata (it does not emit a queryable kind-41001), so the DM inbox reads those directly. This
* registry only tracks which of those a given viewer has hidden, so a hidden DM drops out until
* it's re-opened.
*
* Hidden sets are kept per-viewer because the 30622 snapshot is `#p`-gated to its owner and the
* process can switch accounts. Mutations are lock-guarded because consume runs across several relay
* reader threads. Like [BuzzRelayDialect] / `BuzzWorkspaces`, a process-wide singleton.
*/
object BuzzDmRegistry {
private val lock = KmpLock()
private val hiddenByViewer = HashMap<HexKey, Set<String>>()
private val mutableHidden = MutableStateFlow<Map<HexKey, Set<String>>>(emptyMap())
/** Per-viewer hidden-DM channel ids; the DM inbox collects this to filter itself. */
val hidden: StateFlow<Map<HexKey, Set<String>>> = mutableHidden
/** Replaces [viewer]'s hidden-DM set with [channelIds] (the whole 30622 snapshot). */
fun recordHidden(
viewer: HexKey,
channelIds: Set<String>,
) = lock.withLock {
if (hiddenByViewer[viewer] == channelIds) return@withLock
if (channelIds.isEmpty()) {
hiddenByViewer.remove(viewer)
} else {
hiddenByViewer[viewer] = channelIds
}
mutableHidden.value = hiddenByViewer.toMap()
}
/** The channel ids [viewer] has hidden (possibly empty). */
fun hiddenFor(viewer: HexKey): Set<String> = mutableHidden.value[viewer] ?: emptySet()
/** Test-only: clears all registry state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
hiddenByViewer.clear()
mutableHidden.value = emptyMap()
}
}
@@ -0,0 +1,100 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
* authorization per agent key that lets that key publish in the owner's Buzz workspace
* without being enrolled as a relay member.
*
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
* agent operator out-of-band): when the account whose pubkey equals a stored key
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
* membership while the owner stays a member.
*
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
* store never carries a credential the relay would reject.
*
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
* only: a held attestation is re-pasted after a process restart. Persisting it across
* launches (per-account, encrypted) is a follow-up.
*/
object BuzzHeldAttestations {
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
/** All held attestations, keyed by agent pubkey; UI can collect this. */
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
/** The attestation held for [agentPubKey], or null. */
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
/**
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
* verified attestation is held for that key.
*/
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
/**
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
* writers don't clobber each other.
*/
fun put(
agentPubKey: HexKey,
attestation: OwnerAttestation,
) {
while (true) {
val current = heldByAgent.value
if (current[agentPubKey] == attestation) return
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
}
}
/** Removes any attestation held for [agentPubKey]. */
fun remove(agentPubKey: HexKey) {
while (true) {
val current = heldByAgent.value
if (agentPubKey !in current) return
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
}
}
/**
* Replaces the whole store with [entries] — used to restore from disk at startup. The
* caller must have re-verified each attestation against its agent key (the same gate
* [put] documents), so a tampered on-disk credential can't be reinstated.
*/
fun restore(entries: Map<HexKey, OwnerAttestation>) {
heldByAgent.value = entries
}
/** Test-only: clears all held attestations so unit tests don't leak state. */
fun clearForTesting() {
heldByAgent.value = emptyMap()
}
}
@@ -0,0 +1,81 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Live per-user presence for Buzz workspaces, fed by the ephemeral kind-20001 presence update
* (`PresenceUpdateEvent`). Buzz relays never store these (20000-29999): a client publishes its
* own status over the WebSocket, and a reader gets back the relay's synthesized snapshot
* (relay-signed, subject in a `p` tag). Either way it is pure in-memory live state.
*
* Unlike [BuzzTypingState], presence is **not** a heartbeat that ages out on a fixed window —
* `online`/`away`/`offline` is a state that holds until the peer publishes a newer one — so this
* keeps the latest status per subject (by event time) rather than pruning on a TTL. The raw wire
* string is kept (the relay's WS path accepts arbitrary status strings); map it through
* `PresenceStatus.fromWire` at the display edge.
*
* Shape: `subjectPubKey -> latest status string`. Mutations are lock-guarded because `LocalCache`
* consume runs on several relay reader threads. Process-wide singleton like [BuzzTypingState].
*/
object BuzzPresenceState {
private val lock = KmpLock()
private var stampByUser: PersistentMap<HexKey, Long> = persistentMapOf()
private val mutablePresence = MutableStateFlow<PersistentMap<HexKey, String>>(persistentMapOf())
/** `subjectPubKey -> latest status string`; the UI collects this and maps to a badge. */
val flow: StateFlow<Map<HexKey, String>> = mutablePresence
/**
* Records [status] for [subject] as of [atSecs]. Keeps the newest by timestamp so an
* out-of-order or duplicated delivery can't clobber a fresher state — an update that is not
* strictly newer than what we already hold is a no-op. Backed by persistent maps so a record
* shares structure with the previous snapshot instead of copying the whole map (this runs on a
* hot relay-consume path).
*/
fun record(
subject: HexKey,
status: String,
atSecs: Long,
) = lock.withLock {
val prev = stampByUser[subject]
if (prev != null && atSecs <= prev) return@withLock
stampByUser = stampByUser.put(subject, atSecs)
mutablePresence.value = mutablePresence.value.put(subject, status)
}
/** The latest known status string for [subject], or `null` if none has arrived. */
fun statusFor(subject: HexKey): String? = mutablePresence.value[subject]
/** Test-only: clears all presence state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
stampByUser = persistentMapOf()
mutablePresence.value = persistentMapOf()
}
}
@@ -0,0 +1,69 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks which relays speak the **Buzz dialect** of NIP-29 — `block/buzz` workspace
* relays whose channel timelines carry Buzz-custom kinds (stream messages 40002,
* edits 40003, diffs 40008, system rows 40099, canvas 40100, forum 45001/45003,
* agent jobs 43001-43006, huddle cards 48100-48103) alongside plain kind-9 chat.
*
* Detection sources, in order of arrival:
* 1. **Event shape** (implemented): the first Buzz-only channel kind consumed from a
* relay marks it (`LocalCache` calls [mark] before materializing the channel). A
* vanilla NIP-29 relay never serves those kinds, so there are no false positives.
* 2. **NIP-11** (future): a relay announcing Buzz in its info document can be marked
* at connect time, before any event arrives.
*
* The registry decides which channel type `LocalCache` materializes for a group
* (`BuzzWorkspaceChannel` vs plain `RelayGroupChannel`) and which timeline kinds the
* group-chat REQ builders put on the wire for that relay. Marks only ever ADD
* capability: un-marking is never needed because a marked relay provably served a
* Buzz kind, and extra requested kinds are harmless on any relay.
*
* Like `LocalCache`, this is a process-wide singleton (one copy per Android process).
*/
object BuzzRelayDialect {
private val buzzRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Relays known to speak the Buzz dialect; UI can collect this for affordances. */
val flow: StateFlow<Set<NormalizedRelayUrl>> = buzzRelays
fun isBuzz(relay: NormalizedRelayUrl): Boolean = relay in buzzRelays.value
/** Marks [relay] as Buzz. Returns true when this call changed the state. */
fun mark(relay: NormalizedRelayUrl): Boolean {
while (true) {
val current = buzzRelays.value
if (relay in current) return false
if (buzzRelays.compareAndSet(current, current + relay)) return true
}
}
/** Test-only: clears all marks so unit tests don't leak state into each other. */
fun clearForTesting() {
buzzRelays.value = emptySet()
}
}

Some files were not shown because too many files have changed in this diff Show More