mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/push-notification-design-fzyutz
This commit is contained in:
@@ -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)
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+107
@@ -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")
|
||||
}
|
||||
}
|
||||
+76
@@ -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")
|
||||
}
|
||||
}
|
||||
+88
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-7
@@ -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()
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+32
-1
@@ -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()
|
||||
|
||||
+29
-6
@@ -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))
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
+52
@@ -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)
|
||||
|
||||
+469
@@ -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\"" }
|
||||
+414
@@ -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)
|
||||
+345
@@ -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
|
||||
}
|
||||
}
|
||||
+145
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -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)
|
||||
}
|
||||
}
|
||||
+163
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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 }
|
||||
}
|
||||
+395
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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()
|
||||
}
|
||||
}
|
||||
+142
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -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)
|
||||
}
|
||||
}
|
||||
+230
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+253
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
+210
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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),
|
||||
)
|
||||
}
|
||||
+192
@@ -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)
|
||||
}
|
||||
}
|
||||
+171
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
+6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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 },
|
||||
|
||||
+40
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+270
@@ -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)
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+321
@@ -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(", ")
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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) })
|
||||
|
||||
|
||||
+187
@@ -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() }
|
||||
}
|
||||
+494
-26
@@ -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
|
||||
|
||||
+17
-3
@@ -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(
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
+103
-1
@@ -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.
|
||||
*
|
||||
|
||||
+53
-20
@@ -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(
|
||||
|
||||
+68
-17
@@ -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,
|
||||
|
||||
+21
-6
@@ -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
|
||||
|
||||
+106
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -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-
|
||||
|
||||
+80
-5
@@ -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,
|
||||
|
||||
+147
-2
@@ -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
|
||||
|
||||
|
||||
+60
-1
@@ -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)))
|
||||
},
|
||||
|
||||
+37
-1
@@ -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,
|
||||
|
||||
+13
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-2
@@ -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/
|
||||
|
||||
+111
-69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class UserProfileReportFeedViewModel(
|
||||
val followersFlow: StateFlow<List<Note>> =
|
||||
user
|
||||
.reports()
|
||||
.receivedReportsByAuthor
|
||||
.reportsNamingUser
|
||||
.map {
|
||||
it.values.flatten().sortedWith(sortingModel)
|
||||
}.sample(500)
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+145
@@ -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))
|
||||
}
|
||||
}
|
||||
+122
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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)) }
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
BIN
Binary file not shown.
+68
@@ -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()
|
||||
}
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+108
@@ -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() }
|
||||
}
|
||||
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
+74
@@ -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()
|
||||
}
|
||||
}
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
+81
@@ -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()
|
||||
}
|
||||
}
|
||||
+69
@@ -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
Reference in New Issue
Block a user