Merge latest main into emoji feature branch

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt
This commit is contained in:
Claude
2026-04-21 13:24:03 +00:00
98 changed files with 5869 additions and 407 deletions
@@ -547,17 +547,17 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
val defaultHomeFollowList = async { parseOrNull<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) }
val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) }
val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) }
val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) }
val defaultPollsFollowList = async { parseOrNull<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
val defaultProductsFollowList = async { parseOrNull<TopFilter>(defaultProductsFollowListStr) ?: TopFilter.AroundMe }
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
val defaultArticlesFollowList = async { parseOrNull<TopFilter>(defaultArticlesFollowListStr) ?: TopFilter.AllFollows }
val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) }
val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) }
val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) }
val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) }
val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) }
val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) }
val nwcWalletsLoaded =
async {
@@ -672,6 +672,20 @@ object LocalPreferences {
return result
}
private fun parseTopFilterOrDefault(
value: String?,
default: TopFilter,
): TopFilter {
if (value.isNullOrEmpty() || value == "null") return default
return try {
JsonMapper.fromJson<TopFilter>(value)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding TopFilter from Preferences with value $value", e)
default
}
}
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
@@ -224,6 +224,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
@@ -486,6 +488,9 @@ class Account(
val liveBrowseEmojiSetsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBrowseEmojiSetsFollowList)
val liveBrowseEmojiSetsFollowListsPerRelay = OutboxLoaderState(liveBrowseEmojiSetsFollowLists, cache, scope).flow
val liveCommunitiesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCommunitiesFollowList)
val liveCommunitiesFollowListsPerRelay = OutboxLoaderState(liveCommunitiesFollowLists, cache, scope).flow
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -1166,6 +1171,34 @@ class Account(
client.publish(signedEvent, relays)
}
suspend fun sendCommunityDefinition(
name: String,
description: String,
moderators: List<ModeratorTag>,
image: String? = null,
rules: String? = null,
relays: List<RelayTag>? = null,
dTag: String,
): CommunityDefinitionEvent? {
if (!isWriteable()) return null
val template =
CommunityDefinitionEvent.build(
name = name,
description = description,
moderators = moderators,
image = image,
rules = rules,
relays = relays,
dTag = dTag,
)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
return signedEvent
}
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
val newEvent = newNote?.event as? ProfileBadgesEvent
@@ -192,6 +192,7 @@ class AccountSettings(
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
@@ -467,6 +468,17 @@ class AccountSettings(
}
}
fun changeDefaultCommunitiesFollowList(name: FeedDefinition) {
changeDefaultCommunitiesFollowList(name.code)
}
fun changeDefaultCommunitiesFollowList(name: TopFilter) {
if (defaultCommunitiesFollowList.value != name) {
defaultCommunitiesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
changeDefaultPicturesFollowList(name.code)
}
@@ -169,9 +169,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -1495,6 +1497,44 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
fun consume(
event: LiveActivitiesRaidEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val fromAddress = event.fromAddress()
val toAddress = event.toAddress()
if (fromAddress == null && toAddress == null) return false
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val note = getOrCreateNote(event.id)
fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
}
return new
}
fun consume(
event: LiveActivitiesClipEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val activityAddress = event.activityAddress() ?: return false
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val channel = getOrCreateLiveChannel(activityAddress)
val note = getOrCreateNote(event.id)
channel.addNote(note, relay)
}
return new
}
@Suppress("UNUSED_PARAMETER")
fun consume(
event: ChannelHideMessageEvent,
@@ -1515,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return false
// Already processed this event — still ensure it's routed into any live-activity
// channel(s) it references. A zap that was first consumed by, e.g., the notifications
// subscription must still appear in the stream's chat when the user opens the stream.
if (note.event != null) {
attachZapToLiveActivityChannel(event, note, relay)
return false
}
if (wasVerified || justVerify(event)) {
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
@@ -1541,6 +1587,8 @@ object LocalCache : ILocalCache, ICacheProvider {
repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
@@ -1549,6 +1597,27 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
private fun attachZapToLiveActivityChannel(
event: LnZapEvent,
note: Note,
relay: NormalizedRelayUrl?,
) {
// Match zap.stream: only show zaps whose receiver is the live activity host.
val hosts = event.zappedAuthor().toHashSet()
if (hosts.isEmpty()) return
// Route into every live-activity address this zap references (zap.stream uses one, but
// a receipt could legitimately reference multiple simulcasted streams).
event.tags
.asSequence()
.mapNotNull(ATag::parseAddress)
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts }
.distinct()
.forEach { address ->
getOrCreateLiveChannel(address).addNote(note, relay)
}
}
fun consume(
event: LnZapRequestEvent,
relay: NormalizedRelayUrl?,
@@ -2647,6 +2716,8 @@ object LocalCache : ILocalCache, ICacheProvider {
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified)
is LiveActivitiesClipEvent -> consume(event, relay, wasVerified)
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -31,6 +31,7 @@ data class UiSettings(
val preferredLanguage: String? = null,
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyPlayVideos: BooleanType = BooleanType.ALWAYS,
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
@@ -31,6 +31,7 @@ class UiSettingsFlow(
val preferredLanguage: MutableStateFlow<String?> = MutableStateFlow(null),
val automaticallyShowImages: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyStartPlayback: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyPlayVideos: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyShowUrlPreview: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyHideNavigationBars: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyShowProfilePictures: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
@@ -46,6 +47,7 @@ class UiSettingsFlow(
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyPlayVideos,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
@@ -64,14 +66,15 @@ class UiSettingsFlow(
flows[1] as String?,
flows[2] as ConnectivityType,
flows[3] as ConnectivityType,
flows[4] as ConnectivityType,
flows[5] as BooleanType,
flows[6] as ConnectivityType,
flows[7] as Boolean,
flows[4] as BooleanType,
flows[5] as ConnectivityType,
flows[6] as BooleanType,
flows[7] as ConnectivityType,
flows[8] as Boolean,
flows[9] as FeatureSetType,
flows[10] as ProfileGalleryType,
flows[11] as BooleanType,
flows[9] as Boolean,
flows[10] as FeatureSetType,
flows[11] as ProfileGalleryType,
flows[12] as BooleanType,
)
}
@@ -81,6 +84,7 @@ class UiSettingsFlow(
preferredLanguage.value,
automaticallyShowImages.value,
automaticallyStartPlayback.value,
automaticallyPlayVideos.value,
automaticallyShowUrlPreview.value,
automaticallyHideNavigationBars.value,
automaticallyShowProfilePictures.value,
@@ -110,6 +114,10 @@ class UiSettingsFlow(
automaticallyStartPlayback.tryEmit(torSettings.automaticallyStartPlayback)
any = true
}
if (automaticallyPlayVideos.value != torSettings.automaticallyPlayVideos) {
automaticallyPlayVideos.tryEmit(torSettings.automaticallyPlayVideos)
any = true
}
if (automaticallyShowUrlPreview.value != torSettings.automaticallyShowUrlPreview) {
automaticallyShowUrlPreview.tryEmit(torSettings.automaticallyShowUrlPreview)
any = true
@@ -165,6 +173,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.preferredLanguage),
MutableStateFlow(uiSettings.automaticallyShowImages),
MutableStateFlow(uiSettings.automaticallyStartPlayback),
MutableStateFlow(uiSettings.automaticallyPlayVideos),
MutableStateFlow(uiSettings.automaticallyShowUrlPreview),
MutableStateFlow(uiSettings.automaticallyHideNavigationBars),
MutableStateFlow(uiSettings.automaticallyShowProfilePictures),
@@ -95,6 +95,7 @@ class UiSharedPreferences(
val UI_LANGUAGE = stringPreferencesKey("ui.language")
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos")
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
@@ -114,6 +115,7 @@ class UiSharedPreferences(
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
@@ -150,6 +152,7 @@ class UiSharedPreferences(
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
@@ -102,12 +102,12 @@ fun VideoView(
accountViewModel: AccountViewModel,
thumbhash: String? = null,
) {
val automaticallyStartPlayback =
remember {
mutableStateOf(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(),
)
}
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) }
// Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded.
// If the user manually tapped the download button, they want it to play.
val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value)
if (blurhash == null && thumbhash == null) {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
@@ -137,7 +137,7 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback.value,
automaticallyStartPlayback = autoplay,
onZoom = onDialog,
hasBlurhash = false,
accountViewModel = accountViewModel,
@@ -185,7 +185,7 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback.value,
automaticallyStartPlayback = autoplay,
onZoom = onDialog,
hasBlurhash = true,
accountViewModel = accountViewModel,
@@ -26,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality
import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -50,6 +52,7 @@ fun VideoViewInner(
controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null,
hasBlurhash: Boolean = false,
isFullscreen: Boolean = false,
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -71,6 +74,10 @@ fun VideoViewInner(
mediaItem = mediaItem,
muted = muted,
) { controller ->
ApplyInitialVideoQuality(
player = controller.controller,
policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST,
)
VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
RenderVideoPlayer(
@@ -0,0 +1,117 @@
/*
* 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.playback.composable.controls
import androidx.annotation.MainThread
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.media3.common.Player
import androidx.media3.common.Tracks
/**
* Which HLS rendition to pick automatically the first time tracks become available for a media
* item. Expressed as an explicit policy instead of a `Boolean` so call sites must commit to one
* value at construction and any future dynamic-state caller is forced to key the effect on it.
*/
enum class VideoQualityPolicy {
/** Lock to the lowest-resolution rendition (feed and PiP: save bandwidth). */
LOWEST,
/** Clear any video override so the player uses adaptive bitrate (fullscreen). */
AUTO,
}
/**
* Applies a default video quality when tracks become available on the given player.
*
* The initial selection is applied once per media item. If the user later changes the quality
* manually, or swaps to a different media item, the new choice wins — we don't reapply for the
* same media id. Selections intentionally don't persist across composable lifecycles, so opening
* a feed video in fullscreen starts with [VideoQualityPolicy.AUTO] and returning to the feed
* starts with [VideoQualityPolicy.LOWEST] again.
*/
@Composable
fun ApplyInitialVideoQuality(
player: Player,
policy: VideoQualityPolicy,
) {
// Tracks the media id we've already initialized so we don't fight user overrides after the
// first application.
val appliedForMediaId = remember(player) { mutableStateOf<String?>(null) }
DisposableEffect(player, policy) {
// Re-arm the guard whenever the player or the policy changes so a new policy gets a
// chance to apply even if the same media id has already been handled under the old one.
appliedForMediaId.value = null
val listener =
object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
applyInitialQuality(player, tracks, policy, appliedForMediaId)
}
}
// Tracks might already be available by the time we attach the listener.
applyInitialQuality(player, player.currentTracks, policy, appliedForMediaId)
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
}
// Invoked from Player.Listener callbacks and DisposableEffect bodies, both of which run on
// the player's application looper (main thread for ExoPlayer). The body mutates Compose state
// and trackSelectionParameters; both are main-thread-only.
@MainThread
private fun applyInitialQuality(
player: Player,
tracks: Tracks,
policy: VideoQualityPolicy,
appliedForMediaId: MutableState<String?>,
) {
val mediaId = player.currentMediaItem?.mediaId ?: return
if (appliedForMediaId.value == mediaId) return
val videoGroup = getVideoTrackGroup(tracks) ?: return
// No point forcing a choice when there's only one rendition, and no future update will
// change that for this media id, so mark it as settled.
if (videoGroup.length <= 1) {
appliedForMediaId.value = mediaId
return
}
when (policy) {
VideoQualityPolicy.AUTO -> {
if (hasVideoOverride(player)) clearVideoOverride(player)
appliedForMediaId.value = mediaId
}
VideoQualityPolicy.LOWEST -> {
// If no supported track has a positive short side yet, leave the guard unset so we
// retry on the next onTracksChanged when real video dimensions arrive.
val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return
selectVideoTrack(player, videoGroup, lowestIndex)
appliedForMediaId.value = mediaId
}
}
}
@@ -51,10 +51,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@@ -119,6 +118,21 @@ fun VideoQualityButton(
}
if (openDialog) {
// Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every
// rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open.
var videoSize by remember(player) { mutableStateOf(player.videoSize) }
DisposableEffect(player) {
val listener =
object : Player.Listener {
override fun onVideoSizeChanged(newSize: VideoSize) {
videoSize = newSize
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 }
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = { openDialog = false },
@@ -126,10 +140,10 @@ fun VideoQualityButton(
) {
VideoQualityChoices(
videoGroup = videoGroup,
currentShortSide = getCurrentPlayingShortSide(tracks),
currentShortSide = currentShortSide,
isAuto = !hasVideoOverride(player),
onSelectAuto = {
clearVideoOverride(player)
if (hasVideoOverride(player)) clearVideoOverride(player)
openDialog = false
},
onSelectTrack = { trackIndex ->
@@ -195,6 +209,7 @@ private data class QualityChoice(
private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> {
val choices = mutableListOf<QualityChoice>()
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
val shortSide = minOf(format.width, format.height)
if (shortSide > 0) {
@@ -210,26 +225,3 @@ private fun formatBitrate(bitrate: Int): String =
bitrate >= 1_000_000 -> String.format(Locale.US, "%.1f Mbps", bitrate / 1_000_000.0)
else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0)
}
@OptIn(UnstableApi::class)
private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO }
private fun clearVideoOverride(player: Player) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
.build()
}
private fun selectVideoTrack(
player: Player,
group: Tracks.Group,
trackIndex: Int,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackIndex))
.build()
}
@@ -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.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 }
// Finds the track with the smallest short side (min(width, height)) in the given video group.
// Returns null if no track has a positive short side. Used to force lowest-resolution playback
// in feeds to save bandwidth. Skips tracks the device can't decode — ExoPlayer would silently
// reject an override pointing at an unsupported track and fall back to adaptive selection.
@OptIn(UnstableApi::class)
internal fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? {
var bestIndex: Int? = null
var bestShortSide = Int.MAX_VALUE
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
val shortSide = minOf(format.width, format.height)
if (shortSide > 0 && shortSide < bestShortSide) {
bestShortSide = shortSide
bestIndex = i
}
}
return bestIndex
}
@OptIn(UnstableApi::class)
internal fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO }
internal fun clearVideoOverride(player: Player) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
.build()
}
@OptIn(UnstableApi::class)
internal fun selectVideoTrack(
player: Player,
group: Tracks.Group,
trackIndex: Int,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackIndex))
.build()
}
@@ -34,6 +34,8 @@ import androidx.compose.runtime.remember
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality
import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
@@ -50,6 +52,12 @@ class PipVideoActivity : ComponentActivity() {
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
// PiP window is small, keep bandwidth low by forcing the lowest
// rendition. User can still manually change quality via controls.
ApplyInitialVideoQuality(
player = controllerState.controller,
policy = VideoQualityPolicy.LOWEST,
)
RegisterBackgroundMedia(controllerState)
RegisterControllerReceiver(controllerState)
WatchControllerForActions(mediaItemData, controllerState)
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -103,6 +104,7 @@ class RelaySubscriptionsCoordinator(
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
val communitiesList = CommunitiesListFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -123,6 +125,7 @@ class RelaySubscriptionsCoordinator(
badges,
profileBadges,
browseEmojiSets,
communitiesList,
channelFinder,
eventFinder,
userFinder,
@@ -570,6 +570,7 @@ private fun RenderImageOrVideo(
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null,
isFullscreen = true,
accountViewModel = accountViewModel,
)
}
@@ -630,6 +631,7 @@ private fun RenderImageOrVideo(
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null,
isFullscreen = true,
accountViewModel = accountViewModel,
)
}
@@ -60,6 +60,7 @@ object ScrollStateKeys {
const val POLLS_CLOSED = "PollsClosedFeed"
const val BADGES_SCREEN = "BadgesFeed"
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
@@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -90,12 +92,16 @@ fun DisappearingScaffold(
// The outer Surface provides the Material container color + onBackground as
// LocalContentColor, matching M3 Scaffold's behaviour (without it, default text
// color falls back to Color.Black and is invisible on the dark theme).
val rootModifier =
val baseModifier =
if (allowBarHide) {
Modifier.imePadding().nestedScroll(connection)
} else {
Modifier.imePadding()
}
val rootModifier =
baseModifier
.let { if (topBar == null) it.statusBarsPadding() else it }
.let { if (bottomBar == null) it.navigationBarsPadding() else it }
Surface(
modifier = rootModifier,
@@ -210,7 +216,7 @@ private fun FloatingButtonHolder(
Box(
modifier =
Modifier.graphicsLayer {
val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0f)
val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0.001f)
scaleX = visible
scaleY = visible
alpha = visible
@@ -93,6 +93,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -227,6 +230,9 @@ fun BuildNavigation(
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Communities> { CommunitiesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewCommunity> { NewCommunityScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditCommunity> { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
@@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Photo
@@ -636,6 +637,14 @@ fun ListContent(
route = Route.Badges,
)
NavigationRow(
title = R.string.communities,
icon = Icons.Outlined.Groups,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Communities,
)
NavigationRow(
title = R.string.discover_marketplace,
icon = Icons.Outlined.Storefront,
@@ -45,6 +45,22 @@ sealed class Route {
@Serializable object Polls : Route()
@Serializable object Communities : Route()
@Serializable object NewCommunity : Route()
@Serializable data class EditCommunity(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.collections.immutable.ImmutableList
@@ -351,7 +352,10 @@ fun RenderOption(
is CommunityName -> {
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
val addressable = it.note as? AddressableNote
val definition = addressable?.event as? CommunityDefinitionEvent
val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: ""
Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is RelayName -> {
@@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
is FhirResourceEvent -> {
RenderFhirResource(baseNote, accountViewModel, nav)
}
@@ -24,9 +24,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -34,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@Composable
fun DisplayFollowingCommunityInPost(
@@ -42,38 +46,46 @@ fun DisplayFollowingCommunityInPost(
nav: INav,
) {
Column(HalfStartPadding) {
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) }
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) }
}
}
@Composable
private fun DisplayCommunity(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val communityTag = note.event?.communityAddress() ?: return
val communityNote = LocalCache.getOrCreateAddressableNote(communityTag)
val communityState by observeNote(communityNote, accountViewModel)
val label = communityShortLabel(communityTag, communityState.note.event as? CommunityDefinitionEvent)
ClickableTextColor(
getCommunityShortName(communityTag),
label,
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) {
nav.nav {
note.event?.communityAddress()?.let { communityTag ->
Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)
note.event?.communityAddress()?.let { addr ->
Route.Community(addr.kind, addr.pubKeyHex, addr.dTag)
}
}
}
}
fun getCommunityShortName(communityAddress: Address): String {
val name =
if (communityAddress.dTag.length > 10) {
communityAddress.dTag.take(10) + "..."
private fun communityShortLabel(
address: Address,
definition: CommunityDefinitionEvent?,
): String {
val raw = definition?.name()?.ifBlank { null } ?: address.dTag
val shortened =
if (raw.length > 12) {
raw.take(12) + "..."
} else {
communityAddress.dTag.take(10)
raw
}
return "/n/$name"
return "/n/$shortened"
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
@@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
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.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -439,6 +441,9 @@ private fun LongCommunityActionOptions(
nav: INav,
) {
Row {
if (note.author?.pubkeyHex == accountViewModel.account.signer.pubKey) {
EditCommunityButton(note, nav)
}
ShareCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
@@ -448,6 +453,22 @@ private fun LongCommunityActionOptions(
}
}
@Composable
fun EditCommunityButton(
note: AddressableNote,
nav: INav,
) {
FilledTonalIconButton(
onClick = { nav.nav(Route.EditCommunity(note.address)) },
) {
Icon(
imageVector = Icons.Outlined.Edit,
modifier = Size18Modifier,
contentDescription = stringRes(R.string.edit_community),
)
}
}
@Composable
fun WatchAddressableNoteFollows(
note: AddressableNote,
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.persistentListOf
@@ -275,6 +276,18 @@ class TopNavFilterState(
)
}
private val _communityRoutes =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
peopleLists,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -302,6 +315,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
val communityRoutes =
_communityRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -364,7 +382,11 @@ class PeopleListName(
class CommunityName(
val note: AddressableNote,
) : Name() {
override fun name() = "/n/${(note.dTag())}"
override fun name(): String {
val definition = note.event as? CommunityDefinitionEvent
val label = definition?.name()?.ifBlank { null } ?: note.dTag()
return "/n/$label"
}
}
@Stable
@@ -138,5 +138,7 @@ class UiSettingsState(
fun startVideoPlayback() = startVideoPlayback.value
fun autoPlayVideos() = uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS
fun showImages() = showImages.value
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter
@@ -57,6 +58,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AccountFeedContentStates(
val account: Account,
@@ -86,6 +89,7 @@ class AccountFeedContentStates(
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
val browseEmojiSetsFeed = FeedContentState(BrowseEmojiSetsFeedFilter(account), scope, LocalCache)
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
@@ -103,6 +107,19 @@ class AccountFeedContentStates(
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
init {
// Marmot group list changes (new group, group marked known, group
// metadata synced) don't flow through LocalCache.newEventBundles, so
// the additive update path can't see them. Force a full feed rebuild
// whenever the list changes so empty groups appear and placeholder
// rows get replaced by real messages.
scope.launch(Dispatchers.IO) {
account.marmotGroupList.groupListChanges.collect {
dmKnown.invalidateData()
}
}
}
suspend fun init() {
notificationSummary.initializeSuspend()
}
@@ -134,6 +151,7 @@ class AccountFeedContentStates(
badgesFeed.updateFeedWith(newNotes)
browseEmojiSetsFeed.updateFeedWith(newNotes)
communitiesList.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
@@ -176,6 +194,7 @@ class AccountFeedContentStates(
badgesFeed.deleteFromFeed(newNotes)
browseEmojiSetsFeed.deleteFromFeed(newNotes)
communitiesList.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
@@ -404,6 +404,15 @@ private suspend fun processMarmotWelcomeFlow(
}
}
is WelcomeResult.AlreadyJoined -> {
// Benign replay of a gift-wrapped Welcome (kind:1059) we already
// processed in a prior session — the relay is just re-delivering
// it after app restart. Log at DEBUG, not WARN.
Log.d("MarmotDbg") {
"processMarmotWelcomeFlow: already joined group=${result.nostrGroupId.take(8)}… — treating Welcome as replay"
}
}
is WelcomeResult.Error -> {
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" }
}
@@ -566,6 +575,9 @@ class GroupEventHandler(
return
}
val chatroom = account.marmotGroupList.getOrCreateGroup(groupId)
eventNote.relays.forEach { chatroom.recordRelayActivity(it, event.createdAt) }
try {
val result = manager.processGroupEvent(event)
Log.d("MarmotDbg") {
@@ -616,6 +628,16 @@ class GroupEventHandler(
Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" }
}
is GroupEventResult.UndecryptableOuterLayer -> {
// Expected for commits + application messages from epochs
// that predate our join — per MLS forward secrecy we
// never held those keys. Not a bug, not a warning.
Log.d("MarmotDbg") {
"GroupEventHandler.add: undecryptable outer layer for group=${result.groupId.take(8)}… " +
"(current + ${result.retainedEpochCount} retained epoch key(s) tried) — likely from before our join"
}
}
is GroupEventResult.Error -> {
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
}
@@ -61,6 +61,9 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
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.types.RenderChangeChannelMetadataNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
@@ -84,6 +87,9 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -109,20 +115,29 @@ fun ChatroomMessageCompose(
accountViewModel = accountViewModel,
nav = nav,
) { canPreview ->
NormalChatNote(
baseNote,
routeForLastRead,
innerQuote,
canPreview,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply,
onWantsToEditDraft,
onScrollToNote,
shouldHighlight,
onHighlightFinished,
)
val event = baseNote.event
if (event is LnZapEvent) {
RenderChatZap(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesRaidEvent) {
RenderChatRaid(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesClipEvent) {
RenderChatClip(baseNote, accountViewModel, nav)
} else {
NormalChatNote(
baseNote,
routeForLastRead,
innerQuote,
canPreview,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply,
onWantsToEditDraft,
onScrollToNote,
shouldHighlight,
onHighlightFinished,
)
}
}
}
}
@@ -0,0 +1,118 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
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.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
@Composable
fun RenderChatClip(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val clip = baseNote.event as? LiveActivitiesClipEvent ?: return
val videoUrl = remember(clip) { clip.videoUrl() }
val title = remember(clip) { clip.title() }
val authorHex = remember(clip) { clip.pubKey }
val accent = MaterialTheme.colorScheme.primary
val accentAlpha = 0.12f
StreamSystemCard(accent = accent, accentAlpha = accentAlpha) {
val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = accentAlpha)) }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
}
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_clip_created_a_clip),
color = accent,
fontWeight = FontWeight.Bold,
)
}
if (!title.isNullOrBlank()) {
Text(text = title)
}
val caption = clip.content
if (caption.isNotBlank()) {
CrossfadeToDisplayComment(
comment = caption,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
if (!videoUrl.isNullOrBlank()) {
VideoView(
videoUri = videoUrl,
mimeType = null,
title = title,
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -0,0 +1,141 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
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.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
@Composable
fun RenderChatRaid(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val raid = baseNote.event as? LiveActivitiesRaidEvent ?: return
val from = remember(raid) { raid.fromAddress() }
val to = remember(raid) { raid.toAddress() }
// Without both endpoints we can't render a meaningful raid card.
if (from == null || to == null) return
val accent = MaterialTheme.colorScheme.primary
StreamSystemCard(
accent = accent,
accentAlpha = 0.14f,
onClick = { nav.nav(to.toRoute()) },
) {
val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) }
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
}
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_raid_is_raiding),
color = accent,
fontWeight = FontWeight.Bold,
)
Spacer(StdHorzSpacer)
UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
}
}
val message = raid.content
if (message.isNotBlank()) {
Spacer(Modifier.padding(top = 2.dp))
CrossfadeToDisplayComment(
comment = message,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
tint = accent,
modifier = Size20Modifier,
)
}
}
}
private fun Address.toRoute(): Route = Route.LiveActivityChannel(kind, pubKeyHex, dTag)
@@ -0,0 +1,133 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
private const val BIG_ZAP_THRESHOLD_SATS = 50_000L
@Composable
fun RenderChatZap(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val zapEvent = baseNote.event as? LnZapEvent ?: return
val card by produceState(
ZapAmountCommentNotification(user = null, comment = null, amount = null),
baseNote,
) {
value = accountViewModel.innerDecryptAmountMessage(baseNote) ?: value
}
val isBigZap =
remember(zapEvent) {
(zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS
}
val accentAlpha = if (isBigZap) 0.18f else 0.08f
val amountText = card.amount ?: showAmountInteger(zapEvent.amount)
StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) {
val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) }
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ZapIcon(
if (isBigZap) Size24Modifier else Size20Modifier,
BitcoinOrange,
)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val sender = card.user
if (sender != null) {
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
UsernameDisplay(
baseUser = sender,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
} else {
Text(
text = stringRes(R.string.chat_zap_anonymous),
fontWeight = FontWeight.Bold,
)
}
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_zap_amount_suffix, amountText),
color = BitcoinOrange,
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
)
}
card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
Spacer(Modifier.padding(top = 2.dp))
CrossfadeToDisplayComment(
comment = comment,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
}
}
}
}
@@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.GroupAdd
@@ -100,7 +98,7 @@ fun MarmotGroupChatScreen(
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
Column(Modifier.padding(it)) {
MarmotGroupChatView(
nostrGroupId = nostrGroupId,
accountViewModel = accountViewModel,
@@ -21,15 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
@@ -54,18 +63,29 @@ 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.LocalClipboard
import androidx.compose.ui.platform.LocalContext
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.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
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.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -84,6 +104,7 @@ fun MarmotGroupInfoScreen(
val groupDescription by chatroom.description.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle()
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
@@ -154,11 +175,11 @@ fun MarmotGroupInfoScreen(
modifier = Modifier.padding(top = 4.dp),
)
if (groupRelays.isNotEmpty()) {
Text(
text = "Relays: ${groupRelays.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
GroupRelayList(
relayUrls = groupRelays,
relayActivity = relayActivity,
accountViewModel = accountViewModel,
nav = nav,
)
}
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
@@ -338,3 +359,117 @@ fun LeaveGroupDialog(
},
)
}
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun GroupRelayList(
relayUrls: List<String>,
relayActivity: Map<NormalizedRelayUrl, Long>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val normalized =
remember(relayUrls) {
relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } }
}
if (normalized.isEmpty()) return
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = "Relays",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FlowRow(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val nowSeconds = System.currentTimeMillis() / 1000L
normalized.forEach { (raw, relay) ->
val lastSeen = relayActivity[relay]
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
GroupRelayTile(
relay = relay,
fallbackUrl = raw,
isActive = isActive,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupRelayTile(
relay: NormalizedRelayUrl,
fallbackUrl: String,
isActive: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val clickableModifier =
remember(relay) {
Modifier
.size(55.dp)
.combinedClickable(
indication = ripple24dp,
interactionSource = MutableInteractionSource(),
onLongClick = {
scope.launch {
clipboardManager.setText(relay.url)
}
},
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
)
}
Box(
modifier = clickableModifier,
contentAlignment = Alignment.Center,
) {
RenderRelayIcon(
displayUrl = relayInfo.id ?: fallbackUrl,
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
pingInMs = 0,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
iconModifier = LargeRelayIconModifier,
)
val dotColor =
if (isActive) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.placeholderText
}
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
}
}
}
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -84,7 +82,7 @@ fun ChatroomScreen(
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
Column(Modifier.padding(it)) {
ChatroomView(
room = roomId,
draftMessage = draftMessage,
@@ -21,8 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
import androidx.compose.runtime.Composable
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.Channel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,4 +44,18 @@ fun ChannelFilterAssemblerSubscription(
}
KeyDataSourceSubscription(state, dataSource)
// Live streams need the 30311 event to populate `channel.info` before we can read its
// `goal` tag and add the goal+zap subscriptions. Re-invalidate when metadata changes so
// the goal filter fires as soon as the stream event arrives.
if (channel is LiveActivitiesChannel) {
val metadataState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val goalId = channel.info?.goalEventId()
LaunchedEffect(goalId, metadataState) {
dataSource.invalidateFilters()
}
}
}
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
when (val channel = key.channel) {
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since)
is PublicChatChannel -> filterMessagesToPublicChat(channel, since)
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since)
else -> null
is EphemeralChatChannel -> {
filterMessagesToEphemeralChat(channel, since)
}
is PublicChatChannel -> {
filterMessagesToPublicChat(channel, since)
}
is LiveActivitiesChannel -> {
filterMessagesToLiveActivities(channel, since) +
filterGoalForLiveActivities(channel, since)
}
else -> {
null
}
}
override fun id(key: ChannelQueryState) = key.channel
@@ -0,0 +1,66 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
/**
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
* that count toward it.
*
* zap.stream attaches a goal to a 30311 event via `["goal", "<hex event id>"]`.
* We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it.
*/
fun filterGoalForLiveActivities(
channel: LiveActivitiesChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val goalId = channel.info?.goalEventId() ?: return emptyList()
return channel.relays().toSet().flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GoalEvent.KIND),
ids = listOf(goalId),
limit = 1,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("e" to listOf(goalId)),
limit = 200,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
fun filterMessagesToLiveActivities(
channel: LiveActivitiesChannel,
@@ -35,7 +38,13 @@ fun filterMessagesToLiveActivities(
relay = it,
filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesRaidEvent.KIND,
LiveActivitiesClipEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
limit = 200,
since = since?.get(it)?.time,
@@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -127,6 +129,8 @@ fun LiveActivityChannelView(
.weight(1f, true),
) {
ShowVideoStreaming(channel, accountViewModel)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
@@ -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.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.types.GoalProgressBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
/**
* Compact header rendered above the live-activity chat feed when the stream
* has a NIP-75 zap goal attached. Shows the goal title, a progress bar, and
* a one-tap zap button targeting the goal event.
*/
@Composable
fun LiveStreamGoalHeader(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val goalId = channel.info?.goalEventId() ?: return
LoadNote(baseNoteHex = goalId, accountViewModel = accountViewModel) { goalNote ->
if (goalNote != null) {
GoalHeaderContent(goalNote, accountViewModel, nav)
}
}
}
@Composable
private fun GoalHeaderContent(
goalNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val goal = goalNote.event as? GoalEvent ?: return
val title =
remember(goal) {
goal.summary()?.ifBlank { null } ?: goal.content.take(120).ifBlank { null }
}
val goalAmountSats = remember(goal) { (goal.amount() ?: 0L) / 1000 }
if (goalAmountSats <= 0) return
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
Spacer(StdHorzSpacer)
ZapReaction(
baseNote = goalNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
iconSize = Size20dp,
iconSizeModifier = Size20Modifier,
showCounter = false,
nav = nav,
)
}
GoalProgressBar(
note = goalNote,
goalAmountSats = goalAmountSats,
accountViewModel = accountViewModel,
)
}
HorizontalDivider(thickness = 0.5.dp)
Spacer(Modifier.height(2.dp))
}
@@ -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.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import java.math.BigDecimal
/**
* Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches
* zap.stream's TopZappers look: pill chips with avatar + lightning + sats.
*
* State is owned by [LiveStreamTopZappersViewModel], which maintains the aggregation
* incrementally off the UI thread and publishes a stable `List<TopZapperEntry>`.
*/
@Composable
fun LiveStreamTopZappers(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val topZappersVm: LiveStreamTopZappersViewModel =
viewModel(
key = "TopZappers-${channel.address.toValue()}",
factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = LiveStreamTopZappersViewModel(channel) as T
},
)
// Track the goal note lifecycle — pass it to the VM as it resolves, clear on channel change.
val goalId = channel.info?.goalEventId()
if (goalId != null) {
var goalNoteHolder by remember(goalId) {
mutableStateOf(accountViewModel.getNoteIfExists(goalId))
}
LaunchedEffect(goalId) {
if (goalNoteHolder == null) {
goalNoteHolder = accountViewModel.checkGetOrCreateNote(goalId)
}
topZappersVm.setGoalNote(goalNoteHolder)
}
} else {
LaunchedEffect(channel) { topZappersVm.setGoalNote(null) }
}
val entries by topZappersVm.topZappers.collectAsStateWithLifecycle()
if (entries.isEmpty()) return
LazyRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
) {
items(entries, key = { it.bucketKey }) { entry ->
ZapperPill(entry, accountViewModel, nav)
}
}
}
@Composable
private fun ZapperPill(
entry: TopZapperEntry,
accountViewModel: AccountViewModel,
nav: INav,
) {
val clickModifier =
if (entry.isAnonymous) {
Modifier
} else {
Modifier.clickable { nav.nav(Route.Profile(entry.bucketKey)) }
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = clickModifier,
) {
Row(
// Min height matches the avatar so text-only chips (Anonymous) stay aligned
// with the avatar chips in the horizontal row.
modifier =
Modifier
.heightIn(min = Size24dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (entry.isAnonymous) {
Text(
text = stringRes(R.string.chat_zap_anonymous),
style = MaterialTheme.typography.labelMedium,
)
} else {
UserPicture(
userHex = entry.bucketKey,
size = Size24dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
style = MaterialTheme.typography.labelMedium,
)
Spacer(Modifier.padding(start = 2.dp))
}
}
}
@@ -85,7 +85,11 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.event != null) {
val isEmptyMarmotPlaceholder =
baseNote.event == null &&
baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true
if (baseNote.event != null || isEmptyMarmotPlaceholder) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
val hasEvent by observeNoteHasEvent(baseNote, accountViewModel)
@@ -248,21 +252,27 @@ private fun MarmotGroupRoomCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
val authorName by observeUserName(lastMessage.author!!, accountViewModel)
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val author = lastMessage.author
val noteEvent = lastMessage.event
val description = noteEvent?.content?.take(200)
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
val lastContent =
if (author != null && noteEvent != null) {
val authorName by observeUserName(author, accountViewModel)
"$authorName: ${noteEvent.content.take(200)}"
} else {
stringRes(R.string.marmot_group_no_messages_yet)
}
ChannelName(
channelIdHex = chatroom.nostrGroupId,
channelPicture = null,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) },
channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description",
channelLastContent = lastContent,
hasNewMessages = unread > 0,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
@@ -79,7 +79,7 @@ class ChatroomListKnownFeedFilter(
val marmotGroups =
account.marmotGroupList.rooms.mapNotNull { _, chatroom ->
chatroom.newestMessage
chatroom.newestMessage ?: chatroom.placeholderNote()
}
return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder)
@@ -0,0 +1,180 @@
/*
* 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.communities.list
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@Composable
fun CommunitiesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
CommunitiesScreen(
feedContentState = accountViewModel.feedStates.communitiesList,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun CommunitiesScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForCommunitiesScreen(feedContentState, accountViewModel)
CommunitiesListFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
CommunitiesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Communities, accountViewModel) { route ->
if (route == Route.Communities) {
feedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
floatingButton = {
NewCommunityButton(nav)
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.COMMUNITIES_LIST) { listState ->
RenderCommunitiesFeed(
feedContentState = feedContentState,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun RenderCommunitiesFeed(
feedContentState: FeedContentState,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
label = "RenderCommunitiesFeed",
accountViewModel = accountViewModel,
) { state ->
when (state) {
is FeedState.Empty -> FeedEmpty(feedContentState::invalidateData)
is FeedState.FeedError -> FeedError(state.errorMessage, feedContentState::invalidateData)
is FeedState.Loaded -> CommunitiesFeedLoaded(state, listState, accountViewModel, nav)
is FeedState.Loading -> LoadingFeed()
}
}
}
@Composable
private fun CommunitiesFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = "CommunitiesListFeed",
modifier = Modifier.fillMaxWidth(),
forceEventKind = CommunityDefinitionEvent.KIND,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@Composable
private fun WatchAccountForCommunitiesScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveCommunitiesFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.communities.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun CommunitiesTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultCommunitiesFollowList
.collectAsStateWithLifecycle()
CommunitiesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultCommunitiesFollowList,
)
}
}
@Composable
private fun CommunitiesTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.communityRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,53 @@
/*
* 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.communities.list
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewCommunityButton(nav: INav) {
FloatingActionButton(
onClick = { nav.nav(Route.NewCommunity) },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringRes(id = R.string.new_community),
modifier = Size26Modifier,
tint = Color.White,
)
}
}
@@ -0,0 +1,125 @@
/*
* 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.communities.list.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.mapNotNullIntoSet
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
class CommunitiesFeedFilter(
account: Account,
) : DiscoverCommunityFeedFilter(account) {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-communities-" + followList().code
override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value
private fun myPubkey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
val notes =
LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note ->
val noteEvent = note.event
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
}
return sort(notes)
}
val filterParams =
FilterByListParams.create(
followLists = account.liveCommunitiesFollowLists.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
val notes =
LocalCache.addressables.mapNotNullIntoSet(CommunityDefinitionEvent.KIND) { key, note ->
val noteEvent = note.event
if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) {
note
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
note
} else {
null
}
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
return collection
.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
}
}
val filterParams =
FilterByListParams.create(
followLists = account.liveCommunitiesFollowLists.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return collection
.mapNotNull { note ->
val noteEvent = note.event
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
listOf(note)
} else if (noteEvent is CommunityPostApprovalEvent) {
noteEvent.communityAddresses().mapNotNull {
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
val definitionEvent = definitionNote.event
if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) {
definitionNote
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent, definitionNote.relays)) {
definitionNote
} else {
null
}
}
} else {
null
}
}.flatten()
.toSet()
}
private fun shouldInclude(
aTag: Address?,
params: FilterByListParams,
comingFrom: List<NormalizedRelayUrl> = emptyList(),
) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom)
}
@@ -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.screen.loggedIn.communities.list.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class CommunitiesListQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class CommunitiesListFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<CommunitiesListQueryState>() {
val group =
listOf(
CommunitiesListSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -18,24 +18,31 @@
* 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.playback.composable.controls
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource
import androidx.media3.common.C
import androidx.media3.common.Tracks
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 }
// Returns the "Xp" value for the currently selected video track. Uses min(width, height) so
// that a portrait video's renditions get the same "360p / 540p / 720p" labels as a landscape
// source — the streaming convention is to label by the short side, not format.height which is
// the long side for portrait content.
fun getCurrentPlayingShortSide(tracks: Tracks): Int? {
val group = getVideoTrackGroup(tracks) ?: return null
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) {
val format = group.getTrackFormat(i)
return minOf(format.width, format.height).takeIf { it > 0 }
}
}
return null
@Composable
fun CommunitiesListFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
CommunitiesListFilterAssemblerSubscription(
accountViewModel.dataSources().communitiesList,
accountViewModel,
)
}
@Composable
fun CommunitiesListFilterAssemblerSubscription(
dataSource: CommunitiesListFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
CommunitiesListQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,104 @@
/*
* 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.communities.list.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class CommunitiesListSubAssembler(
client: INostrClient,
allKeys: () -> Set<CommunitiesListQueryState>,
) : PerUserAndFollowListEoseManager<CommunitiesListQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: CommunitiesListQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val listName = key.listName()
val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled()
return if (listName == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since)
} else {
makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince)
}
}
override fun user(key: CommunitiesListQueryState) = key.account.userProfile()
override fun list(key: CommunitiesListQueryState) = key.listName()
fun CommunitiesListQueryState.listNameFlow() = account.settings.defaultCommunitiesFollowList
fun CommunitiesListQueryState.listName() = listNameFlow().value
fun CommunitiesListQueryState.followsPerRelayFlow() = account.liveCommunitiesFollowListsPerRelay
fun CommunitiesListQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: CommunitiesListQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.communitiesList.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,51 @@
/*
* 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.communities.list.datasource
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
private const val COMMUNITIES_MINE_LIMIT = 300
fun filterCommunitiesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(CommunityDefinitionEvent.KIND),
authors = authors,
limit = COMMUNITIES_MINE_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
@@ -0,0 +1,316 @@
/*
* 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.communities.newCommunity
import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Immutable
data class CommunityRelayEntry(
val url: NormalizedRelayUrl,
val marker: String? = null,
)
@Stable
class NewCommunityModel : ViewModel() {
var account: Account? = null
// When set, publish() reuses this d-tag so the kind-34550 replaceable event
// is updated in place (edit flow). Null in the create flow.
var existingDTag: String? = null
var isPublishing by mutableStateOf(false)
var name by mutableStateOf("")
var description by mutableStateOf("")
var rules by mutableStateOf("")
var existingImageUrl by mutableStateOf<String?>(null)
// Image upload state - mirrors NewBadgeModel.
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var selectedServer by mutableStateOf<ServerName?>(null)
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
var stripMetadata by mutableStateOf(true)
val strippingFailureConfirmation = SuspendableConfirmation()
// Moderator/Relay lists - backed by mutableStateListOf for direct Compose observation.
val moderators = mutableStateListOf<User>()
val relays = mutableStateListOf<CommunityRelayEntry>()
fun init(account: Account) {
if (this.account == account) return
this.account = account
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
/**
* Preloads the form with the current contents of [existing] so the user can edit
* the replaceable kind 34550 event. Keeps the original `d` tag so relays replace
* the previous version instead of creating a new community.
*/
fun loadFrom(existing: CommunityDefinitionEvent) {
existingDTag = existing.dTag()
name = existing.name().orEmpty()
description = existing.description().orEmpty()
rules = existing.rules().orEmpty()
existingImageUrl = existing.image()?.imageUrl
val ownerKey = account?.signer?.pubKey
moderators.clear()
existing
.moderatorKeys()
.asSequence()
.filter { it != ownerKey }
.distinct()
.forEach { pubkey ->
val user = account?.cache?.getOrCreateUser(pubkey) ?: return@forEach
if (moderators.none { it.pubkeyHex == user.pubkeyHex }) {
moderators.add(user)
}
}
relays.clear()
existing.relays().forEach { tag ->
if (relays.none { it.url == tag.url }) {
relays.add(CommunityRelayEntry(tag.url, tag.marker))
}
}
}
fun isEditing(): Boolean = existingDTag != null
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun hasPickedImage(): Boolean = multiOrchestrator != null
fun addModerator(user: User) {
if (moderators.none { it.pubkeyHex == user.pubkeyHex }) {
moderators.add(user)
}
}
fun removeModerator(user: User) {
moderators.removeAll { it.pubkeyHex == user.pubkeyHex }
}
fun addRelay(url: NormalizedRelayUrl) {
if (relays.none { it.url == url }) {
relays.add(CommunityRelayEntry(url))
}
}
fun removeRelay(entry: CommunityRelayEntry) {
relays.removeAll { it.url == entry.url }
}
fun setRelayMarker(
entry: CommunityRelayEntry,
marker: String?,
) {
val index = relays.indexOfFirst { it.url == entry.url }
if (index >= 0) {
relays[index] = entry.copy(marker = marker)
}
}
fun canPost(): Boolean =
!isPublishing &&
name.isNotBlank() &&
description.isNotBlank()
@OptIn(ExperimentalUuidApi::class)
fun publish(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
publishUnsafe(context, onSuccess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
}
@OptIn(ExperimentalUuidApi::class)
private fun publishUnsafe(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
val myAccount = account ?: return
viewModelScope.launch(Dispatchers.IO) {
isPublishing = true
try {
val uploadedUrl =
if (multiOrchestrator != null) {
uploadImageIfAny(context, myAccount, onError) ?: return@launch
} else {
null
}
val imageUrl = uploadedUrl ?: existingImageUrl
val ownerKey = myAccount.signer.pubKey
val moderatorTags =
buildList {
add(ModeratorTag(ownerKey, null, "moderator"))
moderators
.asSequence()
.filter { it.pubkeyHex != ownerKey }
.forEach { add(ModeratorTag(it.pubkeyHex, null, "moderator")) }
}
val relayTags = relays.map { RelayTag(it.url, it.marker) }
val dTag = existingDTag ?: Uuid.random().toString()
val definition =
myAccount.sendCommunityDefinition(
name = name.trim(),
description = description.trim(),
moderators = moderatorTags,
image = imageUrl,
rules = rules.trim().ifBlank { null },
relays = relayTags.ifEmpty { null },
dTag = dTag,
)
if (definition == null) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
return@launch
}
// Auto-follow only on the create flow; editing doesn't change the follow set.
if (existingDTag == null) {
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
myAccount.follow(communityNote)
}
selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) }
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
reset()
onSuccess()
} finally {
isPublishing = false
}
}
}
private suspend fun uploadImageIfAny(
context: Context,
myAccount: Account,
onError: (String, String) -> Unit,
): String? {
val orch = multiOrchestrator ?: return null
val serverToUse = selectedServer ?: defaultServer()
val results =
orch.upload(
alt = name.trim().ifBlank { "Community cover" },
contentWarningReason = null,
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
server = serverToUse,
account = myAccount,
context = context,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (!results.allGood) {
val messages =
results.errors
.map { stringRes(context, it.errorResource, *it.params) }
.distinct()
.joinToString(".\n")
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
return null
}
val uploaded =
results.successful.firstNotNullOfOrNull {
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
} ?: run {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
"Upload succeeded but no image URL was returned by the server.",
)
return null
}
return uploaded.url
}
fun reset() {
name = ""
description = ""
rules = ""
existingImageUrl = null
existingDTag = null
multiOrchestrator = null
moderators.clear()
relays.clear()
selectedServer = defaultServer()
}
}
@@ -0,0 +1,641 @@
/*
* 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.communities.newCommunity
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
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 coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.persistentListOf
@Composable
fun NewCommunityScreen(
accountViewModel: AccountViewModel,
nav: INav,
) = CommunityFormScreen(editing = null, accountViewModel = accountViewModel, nav = nav)
@Composable
fun EditCommunityScreen(
editing: Address,
accountViewModel: AccountViewModel,
nav: INav,
) = CommunityFormScreen(editing = editing, accountViewModel = accountViewModel, nav = nav)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CommunityFormScreen(
editing: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val model: NewCommunityModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel.account) {
model.init(accountViewModel.account)
}
LaunchedEffect(editing) {
if (editing != null) {
val note = LocalCache.getOrCreateAddressableNote(editing)
(note.event as? CommunityDefinitionEvent)?.let { model.loadFrom(it) }
} else {
model.existingDTag = null
}
}
StrippingFailureDialog(model.strippingFailureConfirmation)
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelect(
onImageUri = { uris ->
wantsToPickImage = false
model.setPickedMedia(
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
)
},
)
}
Scaffold(
topBar = {
// Derive the top bar from the parameter, not model.isEditing(), so the
// first composition of the edit screen already shows "Save" before
// loadFrom() runs in its LaunchedEffect.
val isEditing = editing != null
val titleRes = if (isEditing) R.string.edit_community else R.string.new_community
if (isEditing) {
ActionTopBar(
titleRes = titleRes,
postRes = R.string.save,
isActive = model::canPost,
onCancel = {
model.reset()
nav.popBack()
},
onPost = {
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
},
)
} else {
CreatingTopBar(
titleRes = titleRes,
isActive = model::canPost,
onCancel = {
model.reset()
nav.popBack()
},
onPost = {
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
},
)
}
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CommunityImagePicker(
model = model,
accountViewModel = accountViewModel,
onPickImage = { wantsToPickImage = true },
)
OutlinedTextField(
value = model.name,
onValueChange = { model.name = it },
label = { Text(stringRes(R.string.new_community_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
OutlinedTextField(
value = model.description,
onValueChange = { model.description = it },
label = { Text(stringRes(R.string.new_community_description)) },
minLines = 3,
maxLines = 8,
modifier = Modifier.fillMaxWidth(),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
OutlinedTextField(
value = model.rules,
onValueChange = { model.rules = it },
label = { Text(stringRes(R.string.new_community_rules)) },
minLines = 2,
maxLines = 8,
modifier = Modifier.fillMaxWidth(),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
HorizontalDivider()
SectionHeader(R.string.new_community_moderators_section)
ModeratorsSection(
model = model,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
SectionHeader(R.string.new_community_relays_section)
RelaysSection(
model = model,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun SectionHeader(resourceId: Int) {
Text(
text = stringRes(resourceId),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
@Composable
private fun CommunityImagePicker(
model: NewCommunityModel,
accountViewModel: AccountViewModel,
onPickImage: () -> Unit,
) {
val existingUrl = model.existingImageUrl
when {
model.hasPickedImage() -> {
model.multiOrchestrator?.let {
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
ShowImageUploadGallery(
list = it,
onDelete = { model.setPickedMedia(persistentListOf()) },
accountViewModel = accountViewModel,
)
}
}
}
!existingUrl.isNullOrBlank() -> {
ExistingCommunityCover(
url = existingUrl,
onClick = onPickImage,
onClear = { model.existingImageUrl = null },
)
}
else -> {
CommunityImagePlaceholder(onClick = onPickImage)
}
}
}
@Composable
private fun ExistingCommunityCover(
url: String,
onClick: () -> Unit,
onClear: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clickable(onClick = onClick),
contentAlignment = Alignment.TopEnd,
) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(16.dp),
),
)
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(R.string.remove),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun CommunityImagePlaceholder(onClick: () -> Unit) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(16.dp),
).clickable(onClick = onClick)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringRes(R.string.new_community_pick_cover),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.new_community_pick_cover_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
// --- Moderators --------------------------------------------------------------------------------
@Composable
private fun ModeratorsSection(
model: NewCommunityModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
var search by remember { mutableStateOf("") }
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(R.string.new_community_moderators_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Current user is always a moderator (creator)
SelectedModeratorRow(
user = accountViewModel.account.userProfile(),
accountViewModel = accountViewModel,
nav = nav,
isOwner = true,
onRemove = null,
)
model.moderators.toList().forEach { user ->
SelectedModeratorRow(
user = user,
accountViewModel = accountViewModel,
nav = nav,
isOwner = false,
onRemove = { model.removeModerator(user) },
)
}
OutlinedTextField(
value = search,
onValueChange = {
search = it
if (it.length > 1) {
userSuggestions.processCurrentWord(it)
} else {
userSuggestions.reset()
}
},
label = { Text(stringRes(R.string.new_community_add_moderator)) },
placeholder = { Text(stringRes(R.string.new_community_add_moderator_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (search.length > 1) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
if (user.pubkeyHex != accountViewModel.account.userProfile().pubkeyHex) {
model.addModerator(user)
}
search = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
@Composable
private fun SelectedModeratorRow(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
isOwner: Boolean,
onRemove: (() -> Unit)?,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
userHex = user.pubkeyHex,
size = 40.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ModeratorSecondaryLine(user)
}
if (isOwner) {
AssistChip(
onClick = {},
label = { Text(stringRes(R.string.new_community_owner)) },
enabled = false,
colors =
AssistChipDefaults.assistChipColors(
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer,
disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
} else if (onRemove != null) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(R.string.remove),
)
}
}
}
}
@Composable
private fun ModeratorSecondaryLine(user: User) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
val text =
when (val state = nip05StateMetadata) {
is Nip05State.Exists -> {
val name = state.nip05.name
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
}
else -> {
user.pubkeyDisplayHex()
}
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// --- Relays ------------------------------------------------------------------------------------
@Composable
private fun RelaysSection(
model: NewCommunityModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(R.string.new_community_relays_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
model.relays.toList().forEach { entry ->
val info = remember(entry.url) { relaySetupInfoBuilder(entry.url) }
Column {
BasicRelaySetupInfoClickableRow(
item = info,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = {},
onDelete = { model.removeRelay(entry) },
nip11CachedRetriever = Amethyst.instance.nip11Cache,
accountViewModel = accountViewModel,
nav = nav,
)
RelayMarkerChips(
current = entry.marker,
onSelect = { model.setRelayMarker(entry, it) },
)
}
}
RelayUrlEditField(
onNewRelay = { model.addRelay(it) },
modifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun RelayMarkerChips(
current: String?,
onSelect: (String?) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 56.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RelayMarkerOption(
label = stringRes(R.string.new_community_relay_marker_none),
selected = current == null,
onClick = { onSelect(null) },
)
RelayMarkerOption(
label = stringRes(R.string.new_community_relay_marker_author),
selected = current == RelayTag.MARKER_AUTHOR,
onClick = { onSelect(RelayTag.MARKER_AUTHOR) },
)
RelayMarkerOption(
label = stringRes(R.string.new_community_relay_marker_requests),
selected = current == RelayTag.MARKER_REQUESTS,
onClick = { onSelect(RelayTag.MARKER_REQUESTS) },
)
RelayMarkerOption(
label = stringRes(R.string.new_community_relay_marker_approvals),
selected = current == RelayTag.MARKER_APPROVALS,
onClick = { onSelect(RelayTag.MARKER_APPROVALS) },
)
}
}
@Composable
private fun RelayMarkerOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
)
}
@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByPr
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
@@ -91,16 +91,26 @@ fun RenderCommunitiesThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by observeNote(baseNote, accountViewModel)
val noteEvent = noteState.note.event as? CommunityDefinitionEvent ?: return
// Narrow observation: we only care about the CommunityDefinitionEvent itself,
// not every reaction/zap/boost that would otherwise recompose the whole card.
val definition by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
val event = definition ?: return
// Memoize card + moderator list identity so LaunchedEffect(moderators) in
// LoadModerators doesn't retrigger the ParticipantListBuilder traversal on
// every recomposition.
val card =
remember(event.id) {
CommunityCard(
name = event.name()?.ifBlank { null } ?: event.dTag(),
description = event.description(),
cover = event.image()?.imageUrl,
moderators = event.moderatorKeys().toImmutableList(),
)
}
RenderCommunitiesThumb(
CommunityCard(
name = noteEvent.dTag(),
description = noteEvent.description(),
cover = noteEvent.image()?.imageUrl,
moderators = noteEvent.moderatorKeys().toImmutableList(),
),
card,
baseNote,
accountViewModel,
nav,
@@ -193,16 +203,21 @@ fun LoadModerators(
)
}
LaunchedEffect(key1 = moderators) {
// Keying by the note id + moderator identity keeps this effect from
// restarting on every recomposition (reaction/zap updates would otherwise
// reallocate `moderators` and rerun the expensive traversal below).
LaunchedEffect(key1 = baseNote.idHex, key2 = moderators) {
launch(Dispatchers.IO) {
val authorHex = baseNote.author?.pubkeyHex
val hosts =
moderators.mapNotNull { part ->
if (part != baseNote.author?.pubkeyHex) {
if (part != authorHex) {
LocalCache.checkGetOrCreateUser(part)
} else {
null
}
}
val hostSet = hosts.toSet()
val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value
val discoveryTopFilterAuthors =
@@ -217,15 +232,15 @@ fun LoadModerators(
else -> null
}
val followingKeySet = discoveryTopFilterAuthors
val builder = ParticipantListBuilder()
val allParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
builder.followsThatParticipateOn(baseNote, discoveryTopFilterAuthors) - hostSet
val newParticipantUsers =
if (followingKeySet == null) {
if (discoveryTopFilterAuthors == null) {
val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
builder.followsThatParticipateOn(baseNote, allFollows) - hostSet
(hosts + followingParticipants + (allParticipants - followingParticipants))
.toImmutableList()
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -51,16 +52,30 @@ fun filterUserProfileMedia(
?: user.allUsedRelaysOrNull()
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserProfileMediaKinds,
authors = listOf(user.pubkeyHex),
limit = 200,
since = since?.get(relay)?.time,
),
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserProfileMediaKinds,
authors = listOf(user.pubkeyHex),
limit = 200,
since = since?.get(relay)?.time,
),
),
// Clips are authored by viewers but carry a `p` tag identifying the stream host.
// Fetch clips OF this profile's streams so they surface in their gallery.
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LiveActivitiesClipEvent.KIND),
tags = mapOf("p" to listOf(user.pubkeyHex)),
limit = 100,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -130,6 +131,21 @@ fun GalleryThumbnail(
thumbhash = imeta.thumbhash,
)
}
} else if (noteEvent is LiveActivitiesClipEvent) {
noteEvent.videoUrl()?.let { url ->
listOf(
MediaUrlVideo(
url = url,
description = noteEvent.title() ?: noteEvent.content,
hash = null,
blurhash = null,
dim = null,
uri = null,
mimeType = null,
thumbhash = null,
),
)
} ?: emptyList()
} else {
emptyList()
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
@@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter(
user: User,
): Boolean {
val noteEvent = it.event
return (
(
it.event?.pubKey == user.pubkeyHex &&
(
noteEvent is PictureEvent ||
noteEvent is RegularVideoEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
val authoredByUser =
it.event?.pubKey == user.pubkeyHex &&
(
noteEvent is PictureEvent ||
noteEvent is RegularVideoEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
// Clips are authored by viewers, not the host — accept them when they reference
// a stream hosted by this user AND carry a playable URL.
val clipOfUsersStream =
noteEvent is LiveActivitiesClipEvent &&
noteEvent.host() == user.pubkeyHex &&
!noteEvent.videoUrl().isNullOrBlank()
return (authoredByUser || clipOfUsersStream) &&
params.match(noteEvent, it.relays) &&
account.isAcceptable(it)
}
@@ -116,6 +116,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
ShowThemeChoice(sharedPrefs)
ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs)
AutoplayVideosChoice(sharedPrefs)
ShowUrlPreviewChoice(sharedPrefs)
ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(sharedPrefs)
@@ -264,6 +265,26 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) {
}
}
@Composable
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
val booleanItems =
persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
SettingsRow(
R.string.autoplay_videos,
R.string.autoplay_videos_description,
booleanItems,
autoplayIndex.screenCode,
) {
sharedPrefs.automaticallyPlayVideos.tryEmit(parseBooleanType(it))
}
}
@Composable
fun ShowUrlPreviewChoice(sharedPrefs: UiSettingsFlow) {
val connectivityBasedOptions =
@@ -386,6 +386,37 @@
<string name="polls">Ankiety</string>
<string name="open_polls">Otwarte</string>
<string name="closed_polls">Zamknięte</string>
<string name="badges">Odznaki</string>
<string name="received_badges">Otrzymano</string>
<string name="my_badges">Moje</string>
<string name="awarded_badges">Przyznane</string>
<string name="discover_badges">Odkryj</string>
<string name="new_badge">Nowa odznaka</string>
<string name="edit_badge">Edytuj odznakę</string>
<string name="award_badge">Znak wyróżnienia</string>
<string name="accept_badge">Zaakceptuj</string>
<string name="reject_badge">Odrzuć</string>
<string name="unaccept_badge">Usuń z profilu</string>
<string name="badge_id_label">ID odznaki (d-tag)</string>
<string name="badge_name_label">Nazwa</string>
<string name="badge_name_placeholder">Nazwa odznaki czytelna dla człowieka</string>
<string name="badge_description_label">Opis</string>
<string name="badge_description_placeholder">Za co przyznawana jest ta odznaka?</string>
<string name="badge_image_label">Adres URL obrazu (1024×1024)</string>
<string name="badge_image_placeholder">https://domena.pl/plakietka.png</string>
<string name="badge_thumb_label">URL miniatury (opcjonalnie)</string>
<string name="badge_thumb_placeholder">https://domena.pl/miniatura-plakietki.png</string>
<string name="badge_upload_image_cta">Wgraj obraz</string>
<string name="badge_upload_image_hint">Wybierz kwadratowe zdjęcie, które posłuży jako wizerunek Twojej odznaki.</string>
<string name="award_badge_loading">Ładowanie odznaki…</string>
<string name="award_badge_search_label">Szukaj użytkowników</string>
<string name="award_badge_search_placeholder">Nazwa, npub lub NIP-05</string>
<string name="award_badge_remove_recipient">Usuń</string>
<string name="badge_awardees_label">Przyznano dla %1$d</string>
<string name="badge_award_received_title">Otrzymałeś odznakę</string>
<string name="profile_badges_header">Odznaki · %1$d</string>
<string name="profile_badges_description">Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.</string>
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
<string name="pictures">Zdjęcia</string>
<string name="shorts">Filmiki</string>
<string name="longs">Filmy wideo</string>
@@ -400,6 +431,8 @@
<string name="pinned_notes_explainer">Twoje przypięte notatki</string>
<string name="pin_to_profile">Przypnij do profilu</string>
<string name="unpin_from_profile">Odepnij z profilu</string>
<string name="deleted_items_banner_remove">Usuń z listy</string>
<string name="deleted_items_banner_dismiss">Odrzuć</string>
<string name="bookmark_lists">Listy zakładek</string>
<string name="bookmark_list_icon_label">Ikona listy zakładek</string>
<string name="bookmark_list_creation_screen_title">Nowa lista zakładek</string>
@@ -412,6 +445,8 @@
<string name="bookmark_list_links_btn_label">Zobacz linki</string>
<string name="bookmark_list_hashtags_btn_label">Zobacz Hashtagi</string>
<string name="bookmark_list_feed_empty_msg">Nie masz jeszcze żadnych list zakładek. Naciśnij nowy przycisk poniżej, aby utworzyć listę.</string>
<string name="interest_set_rename">Zmień nazwę</string>
<string name="interest_set_clone">Sklonuj</string>
<string name="private_posts_label">Prywatne posty</string>
<string name="private_posts_count">Prywatnych postów: (%1$s)</string>
<string name="public_posts_label">Publiczne posty</string>
@@ -1486,7 +1521,15 @@
<string name="feed_group_locations">Lokalizacje</string>
<string name="feed_group_communities">Społeczności</string>
<string name="feed_group_lists">Listy</string>
<string name="feed_group_dvms">Algorytmy kanału</string>
<string name="follow_list_all_favorite_dvms">Wszystkie ulubione algorytmy kanału</string>
<string name="feed_group_relays">Transmitery</string>
<string name="add_dvm_to_favorites">Dodaj algorytm kanału do ulubionych</string>
<string name="remove_dvm_from_favorites">Usuń z ulubionych</string>
<string name="dvm_home_status_processing">Przetwarzanie statusu kanału…</string>
<string name="dvm_home_status_payment_required">Ten algorytm kanału jest płatny</string>
<string name="dvm_home_status_error">Algorytm kanału zwrócił błąd</string>
<string name="dvm_home_retry">Ponów</string>
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
<string name="private_message">Wiadomość prywatna</string>
<string name="public_message">Publiczna wiadomość</string>
@@ -271,6 +271,7 @@
因此聊天信息会随着时间的推移而消失。</string>
<string name="public_chat">公共聊天</string>
<string name="marmot_group">MLS 组</string>
<string name="marmot_group_no_messages_yet">尚无消息</string>
<string name="public_chat_title">公开聊天元数据</string>
<string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以:
参与这些聊天。这对特定话题建立的开放式社区来说很重要
@@ -390,6 +391,26 @@
<string name="open_polls">开启</string>
<string name="closed_polls">已关闭</string>
<string name="badges">徽章</string>
<string name="communities">社区</string>
<string name="new_community">新建社区</string>
<string name="edit_community">编辑社区</string>
<string name="new_community_name">社区名</string>
<string name="new_community_description">描述</string>
<string name="new_community_rules">规则 (可选)</string>
<string name="new_community_pick_cover">添加封面图像</string>
<string name="new_community_pick_cover_hint">轻触选取一张照片 — 它将上传到您的媒体服务器。</string>
<string name="new_community_moderators_section">协管</string>
<string name="new_community_moderators_hint">协管可以批准帖子。您始终是协管。</string>
<string name="new_community_add_moderator">添加协管</string>
<string name="new_community_add_moderator_placeholder">姓名, npub, 或 NIP-05</string>
<string name="new_community_owner">所有者</string>
<string name="new_community_relays_section">中继</string>
<string name="new_community_relays_hint">选择托管请求、批准或社区作者元数据的中继。</string>
<string name="new_community_relay_marker_none">任何</string>
<string name="new_community_relay_marker_author">作者</string>
<string name="new_community_relay_marker_requests">请求</string>
<string name="new_community_relay_marker_approvals">批准</string>
<string name="new_community_empty">没有社区匹配此过滤器。</string>
<string name="received_badges">已收到</string>
<string name="my_badges">我的</string>
<string name="awarded_badges">授予的</string>
@@ -452,6 +473,20 @@
<string name="bookmark_list_links_btn_label">查看链接</string>
<string name="bookmark_list_hashtags_btn_label">查看话题标签</string>
<string name="bookmark_list_feed_empty_msg">您还没有任何书签列表。轻按下面的新建按钮制作一个。</string>
<string name="interest_sets_title">兴趣集</string>
<string name="interest_sets_empty">您还没有任何兴趣集。轻按下面的新建按钮制作一个。</string>
<string name="interest_set_create_btn_label">新的兴趣集</string>
<string name="interest_set_creation_screen_title">新的兴趣集</string>
<string name="interest_set_name_label">集合名称</string>
<string name="interest_set_name_placeholder">我的兴趣</string>
<string name="interest_set_hashtag_add_placeholder">添加话题标签</string>
<string name="interest_set_hashtag_private_toggle">私密</string>
<string name="interest_set_hashtag_count">%1$d 个话题标签</string>
<string name="interest_set_actions_dialog_title">兴趣集操作</string>
<string name="interest_set_rename">重命名</string>
<string name="interest_set_clone">克隆</string>
<string name="interest_set_add_hashtag">添加话题标签</string>
<string name="interest_set_toggle_visibility">切换公共/私密</string>
<string name="private_posts_label">私密帖子</string>
<string name="private_posts_count">私密帖子(%1$s)</string>
<string name="public_posts_label">公开帖子</string>
@@ -954,6 +989,7 @@
<string name="theme">主题</string>
<string name="automatically_load_images_gifs">图像预览</string>
<string name="automatically_play_videos">视频播放</string>
<string name="autoplay_videos">自动播放视频</string>
<string name="automatically_show_url_preview">URL 预览</string>
<string name="automatically_hide_nav_bars">沉浸式滚动</string>
<string name="automatically_hide_nav_bars_description">滚动时隐藏导航栏</string>
@@ -1024,6 +1060,7 @@
<string name="theme_description">暗色、亮色或系统主题</string>
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
<string name="automatically_play_videos_description">自动播放视频和 GIF</string>
<string name="autoplay_videos_description">视频在屏幕上可见时自动播放视频</string>
<string name="automatically_show_url_preview_description">显示 URL 预览</string>
<string name="load_image_description">何时加载图像</string>
<string name="copy_stack_to_clipboard">复制 Stack</string>
@@ -1525,6 +1562,7 @@
<string name="select_list_to_filter">选择一个用于过滤订阅源的列表</string>
<string name="feed_group_feeds">源</string>
<string name="feed_group_hashtags">话题标签</string>
<string name="feed_group_interest_sets">兴趣集</string>
<string name="feed_group_locations">位置</string>
<string name="feed_group_communities">社区</string>
<string name="feed_group_lists">列表</string>
+28 -1
View File
@@ -53,6 +53,10 @@
<string name="login_with_a_private_key_to_be_able_to_boost_posts">You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts</string>
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
<string name="chat_zap_anonymous">Anonymous</string>
<string name="chat_raid_is_raiding">is raiding</string>
<string name="chat_clip_created_a_clip">created a clip</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps</string>
<string name="login_with_a_private_key_to_be_able_to_follow">You are using a public key and public keys are read-only. Login with a Private key to be able to follow</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
@@ -284,6 +288,7 @@
<string name="public_chat">Public Chat</string>
<string name="marmot_group">MLS Group</string>
<string name="marmot_group_no_messages_yet">No messages yet</string>
<string name="public_chat_title">Public Chat Metadata</string>
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
can participate on them. They are great for open communities around specific topics.
@@ -420,6 +425,26 @@
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
<string name="badges">Badges</string>
<string name="communities">Communities</string>
<string name="new_community">New Community</string>
<string name="edit_community">Edit Community</string>
<string name="new_community_name">Community name</string>
<string name="new_community_description">Description</string>
<string name="new_community_rules">Rules (optional)</string>
<string name="new_community_pick_cover">Add a cover image</string>
<string name="new_community_pick_cover_hint">Tap to pick a photo — it will be uploaded to your media server.</string>
<string name="new_community_moderators_section">Moderators</string>
<string name="new_community_moderators_hint">Moderators can approve posts. You are always a moderator.</string>
<string name="new_community_add_moderator">Add a moderator</string>
<string name="new_community_add_moderator_placeholder">Name, npub, or NIP-05</string>
<string name="new_community_owner">Owner</string>
<string name="new_community_relays_section">Relays</string>
<string name="new_community_relays_hint">Pick relays that will host requests, approvals, or the community author\'s metadata.</string>
<string name="new_community_relay_marker_none">Any</string>
<string name="new_community_relay_marker_author">Author</string>
<string name="new_community_relay_marker_requests">Requests</string>
<string name="new_community_relay_marker_approvals">Approvals</string>
<string name="new_community_empty">No communities match this filter.</string>
<string name="received_badges">Received</string>
<string name="my_badges">Mine</string>
<string name="awarded_badges">Awarded</string>
@@ -1092,6 +1117,7 @@
<string name="theme">Theme</string>
<string name="automatically_load_images_gifs">Image Preview</string>
<string name="automatically_play_videos">Video Playback</string>
<string name="autoplay_videos">Autoplay Videos</string>
<string name="automatically_show_url_preview">URL Preview</string>
<string name="automatically_hide_nav_bars">Immersive Scrolling</string>
<string name="automatically_hide_nav_bars_description">Hide Nav Bars when Scrolling</string>
@@ -1183,7 +1209,8 @@
<string name="language_description">For the App\'s Interface</string>
<string name="theme_description">Dark, Light or System theme</string>
<string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string>
<string name="automatically_play_videos_description">Automatically plays videos and GIFs</string>
<string name="automatically_play_videos_description">Automatically load videos and GIFs</string>
<string name="autoplay_videos_description">Automatically play videos when visible on screen</string>
<string name="automatically_show_url_preview_description">Show URL previews</string>
<string name="load_image_description">When to load images</string>
@@ -0,0 +1,63 @@
/*
* 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.compose.nip53LiveActivities
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Shared rounded, accent-tinted container used by the centered "system-style"
* cards rendered inside the live stream chat feed (zaps, raids, clips).
*
* Kept platform-neutral so Desktop can consume it alongside Android.
*/
@Composable
fun StreamSystemCard(
accent: Color = MaterialTheme.colorScheme.primary,
accentAlpha: Float = 0.12f,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
val base =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(accent.copy(alpha = accentAlpha))
val clickable = if (onClick != null) base.clickable(onClick = onClick) else base
Box(
modifier = clickable.padding(horizontal = 10.dp, vertical = 8.dp),
content = content,
)
}
@@ -115,6 +115,7 @@ class MarmotManager(
is GroupEventResult.CommitPending,
is GroupEventResult.Duplicate,
is GroupEventResult.UndecryptableOuterLayer,
is GroupEventResult.Error,
-> {}
}
@@ -178,8 +179,24 @@ class MarmotManager(
"KeyPackage credential identity does not match memberPubKey"
}
// Per RFC 9420 §12.4 (and MDK), the outbound kind:445 MUST be
// outer-encrypted with the pre-commit (epoch-N) exporter secret so
// that other existing members still at epoch N can decrypt and
// process the commit. CommitResult.preCommitExporterSecret carries
// that key; the local group state has already advanced to N+1 by
// the time addMember returns, so we can't read it from the group
// any more.
val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes)
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
val commitEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = commitResult.framedCommitBytes,
exporterKey = commitResult.preCommitExporterSecret,
)
// The published kind:445 will echo back from the relay — without this
// dedup our own inbound pipeline would try to re-apply a commit whose
// epoch we've already merged.
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
val welcomeDelivery =
welcomeSender.wrapWelcome(
@@ -266,7 +283,14 @@ class MarmotManager(
targetLeafIndex: Int,
): OutboundGroupEvent {
val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex)
return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
val commitEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = commitResult.framedCommitBytes,
exporterKey = commitResult.preCommitExporterSecret,
)
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
return commitEvent
}
/**
@@ -277,8 +301,16 @@ class MarmotManager(
nostrGroupId: HexKey,
metadata: MarmotGroupData,
): OutboundGroupEvent {
val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
val commitResult =
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
val commitEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = commitResult.framedCommitBytes,
exporterKey = commitResult.preCommitExporterSecret,
)
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
return commitEvent
}
// --- KeyPackage Management ---
@@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
/**
@@ -51,6 +53,14 @@ class MarmotGroupChatroom(
var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0)
/**
* Tracks the most recent createdAt (seconds) of a kind:445 group event
* observed from each relay, keyed by the relay that delivered it. Used by
* the Group Info screen to flag which configured relays are actively
* carrying traffic for this MLS group.
*/
val relayActivity = MutableStateFlow<Map<NormalizedRelayUrl, Long>>(emptyMap())
/**
* True if the local user has ever sent an application message in this
* group, OR explicitly created/owns it. Used by list UIs to split groups
@@ -59,6 +69,27 @@ class MarmotGroupChatroom(
*/
var ownerSentMessage: Boolean = false
// Synthetic note used by list views to represent the group when no
// messages have been received yet. Lazily created and kept stable so
// equality-based feed diffing treats it as the same row across refreshes.
private var cachedPlaceholder: Note? = null
@Synchronized
fun placeholderNote(): Note {
val existing = cachedPlaceholder
if (existing != null) return existing
val created =
Note(placeholderIdHex(nostrGroupId)).apply {
addGatherer(this@MarmotGroupChatroom)
}
cachedPlaceholder = created
return created
}
companion object {
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
}
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
@@ -134,6 +165,16 @@ class MarmotGroupChatroom(
unreadCount.value = 0
}
fun recordRelayActivity(
relay: NormalizedRelayUrl,
createdAt: Long,
) {
relayActivity.update { current ->
val existing = current[relay] ?: 0L
if (createdAt > existing) current + (relay to createdAt) else current
}
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = messages.sortedWith(DefaultFeedOrder)
val toKeep =
@@ -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.commons.nip53LiveActivities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A single entry on a stream's top-zappers leaderboard.
*
* @param bucketKey either a real zapper pubkey or [LiveActivityTopZappersAggregator.ANON_KEY]
* for zaps whose zap-request carried an `anon` tag.
* @param totalSats sum of amounts across every zap that mapped to this bucket.
* @param isAnonymous true when the bucket is the shared anonymous pool.
*/
@Immutable
data class TopZapperEntry(
val bucketKey: HexKey,
val totalSats: Long,
val isAnonymous: Boolean,
)
/**
* Represents a single zap-receipt contribution before aggregation. All inputs are
* plain values so this stays testable in pure Kotlin without Compose or Android.
*
* @param receiptId the kind-9735 receipt event id — used to de-duplicate receipts that
* appear in both the stream's #a and the goal's #e feed.
* @param zapperPubKey pubkey of the zap-request signer (random one-time key for anon/private).
* @param isAnonymous true when the zap-request carried an `anon` tag (empty or encrypted).
* @param sats amount in satoshis.
*/
@Immutable
data class ZapContribution(
val receiptId: HexKey,
val zapperPubKey: HexKey,
val isAnonymous: Boolean,
val sats: Long,
)
/**
* Computes a top-N zap leaderboard for a live stream.
*
* Inputs are raw [ZapContribution]s from any number of sources; the aggregator handles:
* - de-duplication by receipt id (a zap that tags both `#a` and `#e` counts once),
* - anonymous bucketing (all `anon`-tagged zaps collapse into one entry),
* - sum-by-contributor and sort-desc-by-total,
* - top-N truncation.
*
* This is a pure function — no Compose, no Android, no LocalCache — so it can be unit
* tested in isolation and reused from any platform target.
*/
object LiveActivityTopZappersAggregator {
/** Sentinel key that collapses every anonymous / private zap into a single leaderboard entry. */
const val ANON_KEY: HexKey = "anon"
/** Default cap — matches zap.stream's TopZappers limit. */
const val DEFAULT_LIMIT = 10
fun aggregate(
contributions: Iterable<ZapContribution>,
limit: Int = DEFAULT_LIMIT,
): List<TopZapperEntry> {
if (limit <= 0) return emptyList()
// receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e.
val unique = HashMap<HexKey, ZapContribution>()
for (c in contributions) {
unique[c.receiptId] = c
}
if (unique.isEmpty()) return emptyList()
val totals = HashMap<HexKey, Long>(unique.size)
var hasAnon = false
for (c in unique.values) {
val key = if (c.isAnonymous) ANON_KEY else c.zapperPubKey
if (c.isAnonymous) hasAnon = true
totals[key] = (totals[key] ?: 0L) + c.sats
}
return totals.entries
.asSequence()
.sortedByDescending { it.value }
.take(limit)
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY && hasAnon) }
.toList()
}
}
@@ -0,0 +1,173 @@
/*
* 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.viewmodels
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
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.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Live-stream top-zappers leaderboard state, computed off the UI thread and maintained
* incrementally.
*
* The leaderboard draws from two independent contribution sets keyed by zap-receipt id:
* - [streamContributions] — zaps routed into the [LiveActivitiesChannel]'s cache via the
* stream's `#a` subscription;
* - [goalContributions] — zaps attached to a NIP-75 goal note via `#e`.
*
* The union is flattened on each change and fed into [LiveActivityTopZappersAggregator] for
* de-dup, anonymous bucketing, sum-by-contributor, and top-N sort, publishing a stable
* `List<TopZapperEntry>` via [topZappers] for dumb UI consumption.
*/
@Stable
class LiveStreamTopZappersViewModel(
private val channel: LiveActivitiesChannel,
private val limit: Int = LiveActivityTopZappersAggregator.DEFAULT_LIMIT,
) : ViewModel() {
private val mutex = Mutex()
// receiptId -> ZapContribution, partitioned by source. Aggregator dedupes at merge.
private val streamContributions = HashMap<HexKey, ZapContribution>()
private val goalContributions = HashMap<HexKey, ZapContribution>()
private val _topZappers = MutableStateFlow<List<TopZapperEntry>>(emptyList())
val topZappers: StateFlow<List<TopZapperEntry>> = _topZappers.asStateFlow()
private var goalNote: Note? = null
private var goalObserverJob: Job? = null
init {
viewModelScope.launch(Dispatchers.IO) {
// Seed from the channel cache.
mutex.withLock {
channel.notes.forEach { _, note ->
contributionFromStreamZap(note)?.let { streamContributions[it.receiptId] = it }
}
}
publish()
// Keep in sync with subsequent arrivals.
channel.changesFlow().collect { change ->
val mutated = applyChannelChange(change)
if (mutated) publish()
}
}
}
/**
* Attach (or detach) a NIP-75 goal note whose zaps should flow into the leaderboard.
* Passing the same instance twice is a no-op; passing null clears the goal source.
*/
fun setGoalNote(newGoalNote: Note?) {
if (newGoalNote === goalNote) return
goalNote = newGoalNote
goalObserverJob?.cancel()
goalObserverJob =
viewModelScope.launch(Dispatchers.IO) {
// Reset goal bucket and re-seed from the new note (if any) before subscribing.
rebuildGoalContributions(newGoalNote)
publish()
if (newGoalNote == null) return@launch
// Observe future goal-zap arrivals. Each emission rebuilds the goal bucket;
// the aggregator already handles de-dup at merge-time.
newGoalNote.flow().zaps.stateFlow.collect {
rebuildGoalContributions(newGoalNote)
publish()
}
}
}
private suspend fun applyChannelChange(change: ListChange<Note>): Boolean =
mutex.withLock {
when (change) {
is ListChange.Addition -> upsertStream(change.item)
is ListChange.Deletion -> removeStream(change.item.idHex)
is ListChange.SetAddition -> change.item.fold(false) { acc, n -> upsertStream(n) || acc }
is ListChange.SetDeletion -> change.item.fold(false) { acc, n -> removeStream(n.idHex) || acc }
}
}
private fun upsertStream(note: Note): Boolean {
val c = contributionFromStreamZap(note) ?: return false
val prev = streamContributions[c.receiptId]
if (prev == c) return false
streamContributions[c.receiptId] = c
return true
}
private fun removeStream(receiptId: HexKey): Boolean = streamContributions.remove(receiptId) != null
private suspend fun rebuildGoalContributions(goal: Note?) {
mutex.withLock {
goalContributions.clear()
goal?.zaps?.forEach { (zapRequestNote, receiptNote) ->
contributionFromGoalZap(zapRequestNote, receiptNote)?.let {
goalContributions[it.receiptId] = it
}
}
}
}
private fun publish() {
val merged = streamContributions.values + goalContributions.values
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
}
private fun contributionFromStreamZap(note: Note): ZapContribution? {
val ev = note.event as? LnZapEvent ?: return null
val request = ev.zapRequest ?: return null
val sats = ev.amount()?.toLong() ?: return null
return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
}
private fun contributionFromGoalZap(
zapRequestNote: Note,
receiptNote: Note?,
): ZapContribution? {
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
val sats = receiptEv.amount()?.toLong() ?: return null
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
}
}
/** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */
private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" }
@@ -0,0 +1,153 @@
/*
* 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.nip53LiveActivities
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.ANON_KEY
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.aggregate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LiveActivityTopZappersAggregatorTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
private fun zap(
receipt: String,
zapper: String = alice,
sats: Long = 100,
anon: Boolean = false,
) = ZapContribution(
receiptId = receipt,
zapperPubKey = zapper,
isAnonymous = anon,
sats = sats,
)
@Test
fun sortsByTotalDescending() {
val result =
aggregate(
listOf(
zap("r1", alice, 100),
zap("r2", bob, 500),
zap("r3", carol, 250),
),
)
assertEquals(listOf(bob, carol, alice), result.map { it.bucketKey })
assertEquals(listOf(500L, 250L, 100L), result.map { it.totalSats })
}
@Test
fun sumsMultipleZapsFromSameContributor() {
val result =
aggregate(
listOf(
zap("r1", alice, 100),
zap("r2", alice, 200),
zap("r3", bob, 250),
),
)
assertEquals(listOf(alice, bob), result.map { it.bucketKey })
assertEquals(300L, result[0].totalSats)
assertEquals(250L, result[1].totalSats)
}
@Test
fun dedupesByReceiptId() {
// Same zap arriving via both #a (stream) and #e (goal) should count once.
val result =
aggregate(
listOf(
zap("r1", alice, 500),
zap("r1", alice, 500),
),
)
assertEquals(1, result.size)
assertEquals(500L, result[0].totalSats)
}
@Test
fun bucketsAnonymousZapsUnderSingleSentinel() {
// Each anon zap has a random one-time pubkey; they must collapse into one entry.
val anon1 = "1".repeat(64)
val anon2 = "2".repeat(64)
val result =
aggregate(
listOf(
zap("r1", alice, 100),
zap("r2", anon1, 200, anon = true),
zap("r3", anon2, 300, anon = true),
),
)
assertEquals(2, result.size)
val anon = result.first { it.isAnonymous }
assertEquals(ANON_KEY, anon.bucketKey)
assertEquals(500L, anon.totalSats)
val realUser = result.first { !it.isAnonymous }
assertEquals(alice, realUser.bucketKey)
assertEquals(100L, realUser.totalSats)
}
@Test
fun doesNotFalselyMarkRealPubkeyAnonymousWhenItCollidesWithSentinel() {
// Defensive: if some stream has a zapper whose pubkey literally equals "anon",
// we don't collide because the sentinel is 4 chars and real pubkeys are 64.
val result =
aggregate(
listOf(
zap("r1", alice, 100),
),
)
assertTrue(result.none { it.isAnonymous })
}
@Test
fun respectsTopNLimit() {
val contributions =
(1..15).map {
zap(receipt = "r$it", zapper = "$it".repeat(64), sats = it.toLong() * 10)
}
val result = aggregate(contributions, limit = 5)
assertEquals(5, result.size)
// Highest total first.
assertEquals(15 * 10L, result[0].totalSats)
assertEquals(11 * 10L, result[4].totalSats)
}
@Test
fun handlesEmptyInput() {
assertTrue(aggregate(emptyList()).isEmpty())
}
@Test
fun handlesZeroLimit() {
assertTrue(aggregate(listOf(zap("r1")), limit = 0).isEmpty())
}
}
@@ -79,6 +79,21 @@ sealed class GroupEventResult {
val groupId: HexKey,
) : GroupEventResult()
/**
* The outer ChaCha20-Poly1305 layer could not be decrypted with the
* current-epoch exporter key or any retained prior-epoch key.
*
* This is the expected outcome whenever a member receives a kind:445
* from an epoch before they joined (via Welcome). Per MLS forward
* secrecy, the new member never held those keys, so the bytes are
* unreadable to them — and that is by design, not an error. Callers
* should surface this at DEBUG, not WARN.
*/
data class UndecryptableOuterLayer(
val groupId: HexKey,
val retainedEpochCount: Int,
) : GroupEventResult()
/**
* The event could not be processed.
*/
@@ -101,6 +116,20 @@ sealed class WelcomeResult {
val needsKeyPackageRotation: Boolean,
) : WelcomeResult()
/**
* The Welcome was for a group we're already a member of — benign replay.
*
* Happens after an app restart: the gift-wrapped Welcome (kind:1059) is
* still sitting on the relay and gets redelivered, but the KeyPackage
* bundle it referenced was already consumed and marked. Rather than
* logging a noisy "No matching KeyPackageBundle" error, we detect the
* replay up front by checking `groupManager.isMember(hintNostrGroupId)`
* and return this result. Callers should log at DEBUG.
*/
data class AlreadyJoined(
val nostrGroupId: HexKey,
) : WelcomeResult()
/**
* The Welcome could not be processed.
*/
@@ -176,15 +205,25 @@ class MarmotInboundProcessor(
val result =
try {
// Step 1: Outer ChaCha20-Poly1305 decryption
val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent())
val mlsBytes = tryDecryptOuterLayer(groupId, groupEvent.encryptedContent())
if (mlsBytes == null) {
// Expected when this kind:445 was encrypted with an epoch
// key that predates our join (classical MLS forward
// secrecy), or when the sender's epoch has drifted. Not
// an error — callers should log at DEBUG.
GroupEventResult.UndecryptableOuterLayer(
groupId,
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
)
} else {
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
}
}
} catch (e: Exception) {
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
@@ -245,6 +284,22 @@ class MarmotInboundProcessor(
"MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…"
}
// Short-circuit if we're already a member of the group the
// Welcome is inviting us to. Happens every time the app
// restarts: the gift-wrapped kind:1059 is still on the relay
// and gets redelivered, but the KeyPackage bundle it
// referenced was already consumed + marked during the first
// processing. Without this check the fallthrough below would
// log a noisy "No matching KeyPackageBundle" warning for what
// is actually a benign replay.
if (hintNostrGroupId != null && groupManager.isMember(hintNostrGroupId)) {
com.vitorpamplona.quartz.utils.Log
.d("MarmotDbg") {
"MarmotInboundProcessor.processWelcome: already a member of group=${hintNostrGroupId.take(8)}… — treating Welcome as replay"
}
return WelcomeResult.AlreadyJoined(hintNostrGroupId)
}
// Find the KeyPackageBundle that was consumed.
//
// The Welcome's "e" tag carries the *Nostr event id* of the
@@ -287,6 +342,31 @@ class MarmotInboundProcessor(
WelcomeResult.Error("Failed to process Welcome: ${e.message}", e)
}
/**
* Mark a kind:445 event id as already processed so that a later relay
* echo of the same event is treated as a [GroupEventResult.Duplicate]
* instead of being re-applied.
*
* Callers should invoke this right after publishing a commit (e.g. from
* [com.vitorpamplona.amethyst.commons.marmot.MarmotManager.addMember])
* because `group.addMember` / `group.commit` have already advanced the
* local epoch. Reprocessing the same commit bytes would otherwise fail
* with a confirmation-tag / transcript mismatch.
*/
suspend fun markEventProcessed(eventId: HexKey) {
processedIdsMutex.withLock {
processedEventIds.add(eventId)
if (processedEventIds.size > MAX_PROCESSED_IDS) {
val iterator = processedEventIds.iterator()
val toRemove = processedEventIds.size - MAX_PROCESSED_IDS
repeat(toRemove) {
iterator.next()
iterator.remove()
}
}
}
}
/**
* Resolve any pending commit conflicts for a given epoch.
*
@@ -421,7 +501,12 @@ class MarmotInboundProcessor(
commitEvent: GroupEvent,
): GroupEventResult =
try {
val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent())
val mlsBytes =
tryDecryptOuterLayer(groupId, commitEvent.encryptedContent())
?: return GroupEventResult.UndecryptableOuterLayer(
groupId,
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
)
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
@@ -442,17 +527,44 @@ class MarmotInboundProcessor(
WireFormat.PUBLIC_MESSAGE -> {
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
val tag = pubMsg.confirmationTag
if (tag == null) {
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
} else {
groupManager.processCommit(
nostrGroupId = groupId,
commitBytes = pubMsg.content,
senderLeafIndex = pubMsg.sender.leafIndex,
confirmationTag = tag,
)
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
val currentEpoch = groupManager.getGroup(groupId)?.epoch
when {
tag == null -> {
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
}
// Reject commits that are not for our current epoch.
// Happens most commonly when our own already-applied
// commit is echoed back from the relay after an app
// restart (the in-memory dedup set is cleared), and
// the outer layer decrypts via a retained epoch key.
// Calling `processCommit` on a past-epoch commit
// partially mutates tree / groupContext / epochSecrets
// before throwing on the confirmation-tag check,
// leaving the local state diverged from every other
// member's — they then can't decrypt anything we
// send next.
currentEpoch != null && pubMsg.epoch < currentEpoch -> {
GroupEventResult.Duplicate(groupId)
}
currentEpoch != null && pubMsg.epoch > currentEpoch -> {
GroupEventResult.Error(
groupId,
"Commit epoch ${pubMsg.epoch} is ahead of local epoch $currentEpoch; ignoring",
)
}
else -> {
groupManager.processCommit(
nostrGroupId = groupId,
commitBytes = pubMsg.content,
senderLeafIndex = pubMsg.sender.leafIndex,
confirmationTag = tag,
)
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
}
}
}
@@ -470,11 +582,17 @@ class MarmotInboundProcessor(
*
* After a commit advances the epoch, late-arriving messages encrypted
* with the previous epoch's exporter key would fail without this fallback.
*
* Returns null when neither the current epoch key nor any retained key
* decrypts. This happens normally for commits/application messages from
* epochs that predate our join (we never held those keys), so callers
* should treat null as an expected "nothing to do here" outcome and log
* at DEBUG, not as an error.
*/
private fun decryptOuterLayer(
private fun tryDecryptOuterLayer(
groupId: HexKey,
encryptedContent: String,
): ByteArray {
): ByteArray? {
// Try current epoch key first
try {
val exporterKey = groupManager.exporterSecret(groupId)
@@ -493,9 +611,6 @@ class MarmotInboundProcessor(
}
}
// All keys exhausted — throw to let callers produce an error result
throw IllegalStateException(
"Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)",
)
return null
}
}
@@ -121,20 +121,29 @@ class MarmotOutboundProcessor(
/**
* Build a GroupEvent carrying a Commit for publishing.
*
* Used after MlsGroupManager.commit() or addMember()/removeMember().
* The commit bytes are already MLS-formatted.
* Used after MlsGroupManager.stageAddMember()/stageRemoveMember()/etc.
* The commit bytes are already MLS-formatted (PublicMessage envelope).
*
* @param nostrGroupId the Nostr group ID
* @param commitBytes the raw MLS commit bytes from CommitResult
* @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes]
* @param exporterKey optional explicit outer-encryption key. Callers
* publishing a Commit MUST pass the **pre-commit** exporter secret
* (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret])
* so that other existing members at epoch N can decrypt and process
* the commit. If null, falls back to the current epoch's exporter
* secret — which is only correct when the commit has *not* been
* applied locally yet (i.e. this call is made before
* [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]).
* @return the signed GroupEvent ready for relay publishing
*/
suspend fun buildCommitEvent(
nostrGroupId: HexKey,
commitBytes: ByteArray,
exporterKey: ByteArray? = null,
): OutboundGroupEvent {
// Outer ChaCha20-Poly1305 encryption of the MLS commit
val exporterKey = groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
val outerKey = exporterKey ?: groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, outerKey)
// Build the GroupEvent template
val template =
@@ -21,12 +21,19 @@
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Utility functions for KeyPackage lifecycle management (MIP-00).
@@ -88,12 +95,91 @@ object KeyPackageUtils {
/**
* Validates a KeyPackage event has required fields and proper encoding.
*
* Performs the same strict tag-level MIP-00 checks used by MDK so that
* malformed or adversarial events are rejected at parse time:
* - `d` tag is exactly 64 lowercase hex characters (32-byte slot ID)
* - `mls_protocol_version` is exactly "1.0"
* - `mls_ciphersuite` is exactly "0x0001"
* - `mls_extensions` contains both "0xf2ee" (NostrGroupData) and
* "0x000a" (LastResort)
* - `mls_proposals` contains "0x000a" (SelfRemove)
* - `encoding` is "base64" and content is non-empty
* - `i` (keyPackageRef) tag is non-empty
*
* For deep cryptographic checks (KeyPackageRef hash match, credential
* identity == event.pubkey) call [isCryptographicallyValid].
*/
fun isValid(event: KeyPackageEvent): Boolean =
event.encoding() == EncodingTag.BASE64 &&
event.content.isNotEmpty() &&
!event.keyPackageRef().isNullOrEmpty() &&
!event.mlsCiphersuite().isNullOrEmpty()
fun isValid(event: KeyPackageEvent): Boolean {
// d tag: exactly 64 hex chars per MIP-00
val dTag = event.dTag()
if (dTag.length != 64 || !dTag.all { it.isHexChar() }) return false
// mls_protocol_version == "1.0"
if (event.mlsProtocolVersion() != MlsProtocolVersionTag.CURRENT_VERSION) return false
// mls_ciphersuite == "0x0001"
if (event.mlsCiphersuite() != MlsCiphersuiteTag.DEFAULT_CIPHERSUITE) return false
// mls_extensions MUST include both 0xf2ee and 0x000a
val extensions = event.mlsExtensions()?.map { it.lowercase() }?.toSet() ?: return false
if (!extensions.contains("0xf2ee") || !extensions.contains("0x000a")) return false
// mls_proposals MUST include 0x000a (SelfRemove)
val proposals = event.mlsProposals()?.map { it.lowercase() }?.toSet() ?: return false
if (!proposals.contains("0x000a")) return false
// encoding MUST be base64 and content non-empty
if (event.encoding() != EncodingTag.BASE64) return false
if (event.content.isEmpty()) return false
// i (KeyPackageRef) tag MUST be present
if (event.keyPackageRef().isNullOrEmpty()) return false
return true
}
/**
* Deep MIP-00 validation: decodes the KeyPackage content and verifies:
* - `i` tag matches the computed `KeyPackageRef` (RFC 9420 §5.2)
* - Credential identity (BasicCredential) equals the event's `pubkey`
* (32-byte x-only Nostr pubkey)
* - KeyPackage signature over KeyPackageTBS is valid
*
* Returns true only if [isValid] also holds and every cryptographic check
* passes. Requires [isValid] to be true as a precondition — it is called
* internally.
*/
@OptIn(ExperimentalEncodingApi::class)
fun isCryptographicallyValid(event: KeyPackageEvent): Boolean {
if (!isValid(event)) return false
val iTag = event.keyPackageRef() ?: return false
val keyPackage =
try {
val bytes = Base64.decode(event.content)
MlsKeyPackage.decodeTls(TlsReader(bytes))
} catch (_: Throwable) {
return false
}
// i tag MUST equal the computed KeyPackageRef
if (keyPackage.reference().toHexKey() != iTag.lowercase()) return false
// Credential identity MUST equal the event's pubkey (32-byte x-only).
// MIP-00 requires BasicCredential with the raw 32-byte Nostr pubkey.
val credential = keyPackage.leafNode.credential
if (credential !is Credential.Basic) return false
if (credential.identity.size != 32) return false
if (credential.identity.toHexKey().lowercase() != event.pubKey.lowercase()) return false
// KeyPackage signature MUST verify against the LeafNode's signatureKey.
if (!keyPackage.verifySignature()) return false
return true
}
private fun Char.isHexChar(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'
/**
* Builds a rotated KeyPackage for the same d-tag slot.
@@ -100,6 +100,9 @@ data class MarmotGroupData(
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
"disappearing_message_secs must be > 0 when set (MIP-01)"
}
require(adminPubkeys.size == adminPubkeys.toSet().size) {
"MarmotGroupData.admin_pubkeys MUST NOT contain duplicates (MIP-01)"
}
}
/** Whether the given pubkey is an admin of this group */
@@ -111,46 +114,58 @@ data class MarmotGroupData(
/**
* Encode this MarmotGroupData to TLS wire format bytes.
* Mirrors the [decodeTls] format.
*
* Per MIP-01, all variable-length vectors use QUIC-style variable-length integer
* (VarInt) length prefixes, as implemented by the Rust `tls_codec` crate (v0.4+):
* - lengths 0..63 → 1 byte (high bits 00)
* - lengths 64..16383 → 2 bytes (high bits 01)
* - lengths 16384+ → 4 bytes (high bits 10)
*/
fun encodeTls(): ByteArray {
val writer = TlsWriter()
writer.putUint16(version)
writer.putBytes(nostrGroupId.hexToByteArray())
writer.putOpaque2(name.encodeToByteArray())
writer.putOpaque2(description.encodeToByteArray())
writer.putOpaqueVarInt(name.encodeToByteArray())
writer.putOpaqueVarInt(description.encodeToByteArray())
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
// admin_pubkeys: Vec<[u8;32]> — outer VarInt covers total bytes, each 32-byte
// key is fixed-size with no inner length prefix.
val adminBytes = ByteArray(adminPubkeys.size * 32)
adminPubkeys.forEachIndexed { index, key ->
key.hexToByteArray().copyInto(adminBytes, index * 32)
}
writer.putOpaque2(adminBytes)
writer.putOpaqueVarInt(adminBytes)
// Relays: length-prefixed block of length-prefixed UTF-8 strings
// relays: Vec<Vec<u8>> — outer VarInt covers total bytes, each inner relay
// string is VarInt-length-prefixed UTF-8.
val relayWriter = TlsWriter()
for (relay in relays) {
relayWriter.putOpaque2(relay.encodeToByteArray())
relayWriter.putOpaqueVarInt(relay.encodeToByteArray())
}
writer.putOpaque2(relayWriter.toByteArray())
writer.putOpaqueVarInt(relayWriter.toByteArray())
// Optional image fields
writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0))
writer.putOpaque2(imageKey ?: ByteArray(0))
writer.putOpaque2(imageNonce ?: ByteArray(0))
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
// Optional image fields — empty Vec<u8> encodes as a single zero byte (VarInt(0)).
writer.putOpaqueVarInt(imageHash?.hexToByteArray() ?: ByteArray(0))
writer.putOpaqueVarInt(imageKey ?: ByteArray(0))
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs)
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
var v = secs.toLong()
for (i in 7 downTo 0) {
out[i] = (v and 0xFF).toByte()
v = v ushr 8
}
out
} ?: ByteArray(0)
writer.putOpaque2(disappearingBytes)
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
// the wire format byte-for-byte compatible with older implementations (MDK v2).
if (version >= 3) {
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
var v = secs.toLong()
for (i in 7 downTo 0) {
out[i] = (v and 0xFF).toByte()
v = v ushr 8
}
out
} ?: ByteArray(0)
writer.putOpaqueVarInt(disappearingBytes)
}
return writer.toByteArray()
}
@@ -182,19 +197,22 @@ data class MarmotGroupData(
/**
* Decode MarmotGroupData from TLS wire format bytes.
*
* Per MIP-01, all variable-length vectors use QUIC-style VarInt length prefixes
* (`tls_codec` v0.4+). The TLS comment syntax below uses `<V>` to denote VarInt.
*
* Wire format (v3):
* ```
* uint16 version // rejected if 0 or unsupported
* opaque nostr_group_id[32]
* opaque name<0..2^16-1>
* opaque description<0..2^16-1>
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
* opaque image_hash<0..32>
* opaque image_key<0..32>
* opaque image_nonce<0..12>
* opaque image_upload_key<0..32>
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* opaque name<V>
* opaque description<V>
* opaque admin_pubkeys<V> // concatenated 32-byte keys
* RelayUrl relays<V> // VarInt-length-prefixed UTF-8 strings
* opaque image_hash<V>
* opaque image_key<V>
* opaque image_nonce<V>
* opaque image_upload_key<V>
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* ```
*
* Unknown trailing bytes from future versions are silently ignored for
@@ -209,14 +227,14 @@ data class MarmotGroupData(
val nostrGroupIdBytes = reader.readBytes(32)
val nostrGroupId = nostrGroupIdBytes.toHexKey()
val nameBytes = reader.readOpaque2()
val nameBytes = reader.readOpaqueVarInt()
val name = nameBytes.decodeToString()
val descriptionBytes = reader.readOpaque2()
val descriptionBytes = reader.readOpaqueVarInt()
val description = descriptionBytes.decodeToString()
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
val adminBlock = reader.readOpaque2()
// Admin pubkeys: concatenated 32-byte keys within a VarInt-prefixed block
val adminBlock = reader.readOpaqueVarInt()
val adminPubkeys = mutableListOf<HexKey>()
var i = 0
while (i + 32 <= adminBlock.size) {
@@ -224,23 +242,23 @@ data class MarmotGroupData(
i += 32
}
// Relays: length-prefixed block of length-prefixed UTF-8 strings
val relaysBlock = reader.readOpaque2()
// Relays: VarInt-prefixed block of VarInt-prefixed UTF-8 strings
val relaysBlock = reader.readOpaqueVarInt()
val relays = mutableListOf<String>()
val relayReader = TlsReader(relaysBlock)
while (relayReader.hasRemaining) {
val relayBytes = relayReader.readOpaque2()
val relayBytes = relayReader.readOpaqueVarInt()
relays.add(relayBytes.decodeToString())
}
// Optional fields — read if remaining
val imageHash = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() }?.toHexKey() else null
val imageKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageHash = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.toHexKey() else null
val imageKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
val imageNonce = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
val imageUploadKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
// v3+: disappearing_message_secs
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
val disappearingBytes = if (reader.hasRemaining) reader.readOpaqueVarInt() else ByteArray(0)
val disappearingMessageSecs: ULong? =
when (disappearingBytes.size) {
0 -> {
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -60,7 +61,9 @@ object WelcomeGiftWrap {
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
): GiftWrapEvent {
// Step 1: Build the WelcomeEvent template and sign it
// Step 1: Build the WelcomeEvent template directly as an unsigned rumor.
// Per NIP-59 rumors MUST have an empty sig field, so we skip the outer
// signature entirely and let the SealedRumorEvent carry authorship.
val welcomeTemplate =
WelcomeEvent.build(
welcomeBase64 = welcomeBase64,
@@ -69,10 +72,14 @@ object WelcomeGiftWrap {
nostrGroupId = nostrGroupId,
createdAt = createdAt,
)
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)
val welcomeRumor: WelcomeEvent =
RumorAssembler.assembleRumor(
pubKey = signer.pubKey,
ev = welcomeTemplate,
)
// Step 2: Create a Rumor from the signed event and seal it (kind:13)
val rumor = Rumor.create(welcomeEvent)
// Step 2: Create a Rumor from the unsigned event and seal it (kind:13)
val rumor = Rumor.create(welcomeRumor)
val sealedRumor =
SealedRumorEvent.create(
rumor = rumor,
@@ -25,30 +25,29 @@ import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.mac.MacInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Handles EncryptedToken creation and decryption for Marmot push notifications (MIP-05).
*
* EncryptedToken format (280 bytes total):
* ephemeral_pubkey(32) || nonce(12) || ciphertext(236 = 220 plaintext + 16 tag)
* EncryptedToken format (MUST be exactly 1084 bytes per MIP-05):
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1040 = 1024 plaintext + 16 tag)
*
* Token payload (220 bytes, padded):
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N)
* Token plaintext (MUST be exactly 1024 bytes per MIP-05):
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(1024-3-N)
*
* Key derivation:
* 1. ECDH: shared_point = ephemeral_privkey * server_pubkey
* 2. shared_x = sha256(shared_point) (x-coordinate as shared secret)
* 3. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x)
* 4. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32)
* 5. Encrypt padded payload with ChaCha20-Poly1305(key, nonce, payload, aad="")
* Key derivation (MIP-05 §"Key Derivation"):
* 1. ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
* 2. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x)
* 3. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32)
* 4. Encrypt padded plaintext with ChaCha20-Poly1305(key, nonce, plaintext, aad="")
*
* Platform values: 0x01 = APNs, 0x02 = FCM
*/
object TokenEncryption {
private const val PADDED_PAYLOAD_SIZE = 220
/** Token plaintext MUST be exactly 1024 bytes per MIP-05. */
private const val PADDED_PAYLOAD_SIZE = 1024
private const val NONCE_SIZE = 12
private const val PUBKEY_SIZE = 32
private const val HEADER_SIZE = 3 // platform(1) + token_length(2)
@@ -97,9 +96,9 @@ object TokenEncryption {
// Extract the 32-byte x-only public key by dropping the SEC1 prefix byte
val ephemeralPubKey = compressedPubKey.copyOfRange(1, 33)
// ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey)
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
val sharedX = sha256(sharedPoint)
// ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
// per MIP-05. Do NOT hash; HKDF-Extract will mix the salt.
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
// HKDF-Extract then Expand to get encryption key
val encryptionKey = hkdfDeriveKey(sharedX)
@@ -108,7 +107,7 @@ object TokenEncryption {
val nonce = RandomInstance.bytes(NONCE_SIZE)
val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey)
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236)
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(1040)
val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE)
ephemeralPubKey.copyInto(result, 0)
nonce.copyInto(result, PUBKEY_SIZE)
@@ -140,9 +139,9 @@ object TokenEncryption {
val nonce = data.copyOfRange(PUBKEY_SIZE, PUBKEY_SIZE + NONCE_SIZE)
val ciphertextWithTag = data.copyOfRange(PUBKEY_SIZE + NONCE_SIZE, data.size)
// ECDH: shared_x = sha256(server_privkey * ephemeral_pubkey)
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey)
val sharedX = sha256(sharedPoint)
// ECDH: shared_x = secp256k1_ecdh(server_privkey, ephemeral_pubkey) — raw 32-byte x
// per MIP-05.
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey)
// Derive encryption key
val encryptionKey = hkdfDeriveKey(sharedX)
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -33,8 +32,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* Unsigned application message sent inside a GroupEvent (kind:445) when a device
* leaves a group or wants to disable push notifications.
*
* Has no tags — the MLS leaf index is implicit from the MLS sender identity.
* Receiving clients MUST remove the token for the identified leaf.
* Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit
* from the MLS sender identity; receiving clients MUST remove the token for
* the identified leaf. Adding extra tags could leak metadata or be rejected
* by strict MIP-05 validators (e.g. the MDK reference).
*
* MUST remain unsigned (no sig field) per MIP-03 security requirements.
*/
@@ -50,11 +51,6 @@ class TokenRemovalEvent(
companion object {
const val KIND = 449
fun build(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TokenRemovalEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
initializer()
}
fun build(createdAt: Long = TimeUtils.now()) = eventTemplate<TokenRemovalEvent>(KIND, "", createdAt)
}
}
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.ensure
*/
@Immutable
data class TokenTagData(
/** Base64-encoded EncryptedToken (280 bytes when decoded) */
/** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */
val encryptedToken: String,
/** Hex-encoded notification server public key */
val serverPubKey: HexKey,
@@ -52,8 +52,11 @@ class TokenTag {
companion object {
const val TAG_NAME = "token"
/** Expected decoded size of an EncryptedToken */
const val ENCRYPTED_TOKEN_SIZE = 280
/**
* Expected decoded size of an EncryptedToken per MIP-05:
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes.
*/
const val ENCRYPTED_TOKEN_SIZE = 1084
fun parse(tag: Array<String>): TokenTagData? {
ensure(tag.has(3) && tag[0] == TAG_NAME) { return null }
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.marmot.mls.framing
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
/**
* MLS MLSMessage (RFC 9420 Section 6).
@@ -161,7 +163,17 @@ data class PublicMessage(
encodeSender(writer, sender)
writer.putOpaqueVarInt(authenticatedData)
writer.putUint8(contentType.value)
writer.putBytes(content)
// RFC 9420 §6 FramedContent.content is a type-dependent body:
// case application: opaque application_data<V>
// case proposal: Proposal proposal (struct, no outer length prefix)
// case commit: Commit commit (struct, no outer length prefix)
// `content` holds the already-serialized body for proposal/commit, and
// the raw application bytes for application.
when (contentType) {
ContentType.APPLICATION -> writer.putOpaqueVarInt(content)
ContentType.PROPOSAL, ContentType.COMMIT -> writer.putBytes(content)
}
// FramedContentAuthData
writer.putOpaqueVarInt(signature)
@@ -191,9 +203,30 @@ data class PublicMessage(
val authenticatedData = reader.readOpaqueVarInt()
val contentType = ContentType.fromValue(reader.readUint8())
// Content is variable based on content_type, read remaining content
// For now, read as opaque
val content = reader.readOpaqueVarInt()
// RFC 9420 §6 FramedContent.content body varies by content_type.
// For PROPOSAL/COMMIT we decode the inner struct to advance the reader
// and then re-serialize back to bytes so the invariant
// "content holds the serialized body" holds for all variants.
val content: ByteArray =
when (contentType) {
ContentType.APPLICATION -> {
reader.readOpaqueVarInt()
}
ContentType.PROPOSAL -> {
val proposal = Proposal.decodeTls(reader)
val w = TlsWriter()
proposal.encodeTls(w)
w.toByteArray()
}
ContentType.COMMIT -> {
val commit = Commit.decodeTls(reader)
val w = TlsWriter()
commit.encodeTls(w)
w.toByteArray()
}
}
val signature = reader.readOpaqueVarInt()
val confirmationTag =
@@ -31,6 +31,9 @@ import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
import com.vitorpamplona.quartz.marmot.mls.framing.ContentType
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
import com.vitorpamplona.quartz.marmot.mls.framing.Sender
import com.vitorpamplona.quartz.marmot.mls.framing.SenderType
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
@@ -400,6 +403,13 @@ class MlsGroup private constructor(
// (i.e. no remaining member appears in the post-commit admin list).
enforceNoAdminDepletion(proposals)
// Capture the pre-commit exporter secret BEFORE any mutation.
// Publishers of the outbound kind:445 MUST outer-encrypt with this
// key (epoch N) so that other existing members at epoch N can decrypt
// and process the commit. See CommitResult.preCommitExporterSecret.
val preCommitExporterSecret =
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
// Check if we need an UpdatePath (required unless only SelfRemove)
@@ -502,7 +512,10 @@ class MlsGroup private constructor(
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
val newTreeHash = tree.treeHash()
val newEpoch = groupContext.epoch + 1
val oldEpoch = groupContext.epoch
val preCommitGroupId = groupContext.groupId
val committerLeafIndex = myLeafIndex
val newEpoch = oldEpoch + 1
groupContext =
groupContext.copy(
@@ -544,7 +557,21 @@ class MlsGroup private constructor(
sentKeys.clear()
val commitBytes = commit.toTlsBytes()
return CommitResult(commitBytes, welcomeBytes, null)
val framedCommitBytes =
framePublicMessageCommit(
groupId = preCommitGroupId,
epoch = oldEpoch,
senderLeafIndex = committerLeafIndex,
commitBytes = commitBytes,
confirmationTag = confirmationTag,
)
return CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = null,
framedCommitBytes = framedCommitBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
}
// --- Message Encryption ---
@@ -1475,6 +1502,38 @@ class MlsGroup private constructor(
private const val REUSE_GUARD_LENGTH = 4
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
/**
* Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...))
* envelope so it can be published on the wire (RFC 9420 §6 / §6.2).
*
* The receiver uses the sender's leaf index and the confirmation_tag from
* the [PublicMessage] header to drive [MlsGroup.processCommit]. The
* `signature` and `membership_tag` opaque fields are intentionally empty —
* the current implementation does not verify them on inbound commits,
* but the TLS structure must still be present so decoding succeeds.
*/
internal fun framePublicMessageCommit(
groupId: ByteArray,
epoch: Long,
senderLeafIndex: Int,
commitBytes: ByteArray,
confirmationTag: ByteArray,
): ByteArray {
val publicMessage =
PublicMessage(
groupId = groupId,
epoch = epoch,
sender = Sender(SenderType.MEMBER, senderLeafIndex),
authenticatedData = ByteArray(0),
contentType = ContentType.COMMIT,
content = commitBytes,
signature = ByteArray(0),
confirmationTag = confirmationTag,
membershipTag = ByteArray(0),
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes()
}
/**
* Build ConfirmedTranscriptHashInput (RFC 9420 Section 8.2) — static version
* usable from both instance methods and companion object factory methods.
@@ -2020,7 +2079,9 @@ class MlsGroup private constructor(
/**
* Add a member to the group by their KeyPackage.
* Creates and applies a Commit with an Add proposal.
* Creates and applies a Commit with an Add proposal. The resulting
* [CommitResult.preCommitExporterSecret] is the key the outer kind:445
* MUST be encrypted with (RFC 9420 §12.4 + MDK parity).
*/
fun addMember(keyPackageBytes: ByteArray): CommitResult {
proposeAdd(keyPackageBytes)
@@ -2029,7 +2090,8 @@ class MlsGroup private constructor(
/**
* Remove a member from the group.
* Creates and applies a Commit with a Remove proposal.
* Creates and applies a Commit with a Remove proposal. See [addMember]
* for the pre-commit exporter key contract on the returned [CommitResult].
*/
fun removeMember(targetLeafIndex: Int): CommitResult {
proposeRemove(targetLeafIndex)
@@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock
* ## Cross-Implementation Notes
*
* This manager uses ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets
* for the current and previous epoch are kept for late-message decryption.
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 5, matching the
* `DEFAULT_EPOCH_LOOKBACK` used by the MDK reference implementation so that late-
* arriving MIP-03 GroupEvents (and MLS application messages) whose outer ChaCha20-
* Poly1305 key was derived from a prior epoch's exporter secret can still be
* decrypted after a Commit advances the group.
*
* Thread safety: All suspending mutation methods are guarded by a [Mutex]
* to prevent concurrent state corruption. Non-suspending read methods
@@ -264,11 +267,9 @@ class MlsGroupManager(
suspend fun commit(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -289,10 +290,16 @@ class MlsGroupManager(
) = mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
// Capture the outgoing epoch's secrets BEFORE advancing, but only
// commit them to the retention window once processCommit succeeds —
// otherwise a failed commit (e.g. "Duplicate encryption key" on an
// add-me relay echo) would pollute the window with a duplicate of
// the current epoch key, wasting the finite retention slots.
val retainedBefore = group.retainedSecrets()
group.processCommit(commitBytes, senderLeafIndex, confirmationTag)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
}
@@ -356,6 +363,12 @@ class MlsGroupManager(
/**
* Add a member and create a Commit.
*
* The returned [CommitResult.preCommitExporterSecret] is the key the
* outbound kind:445 MUST be outer-encrypted with (RFC 9420 §12.4 + MDK
* parity). The local group state advances to the new epoch before this
* function returns; publishers get one shot at the correct pre-commit
* key via the [CommitResult] payload.
*/
suspend fun addMember(
nostrGroupId: HexKey,
@@ -363,14 +376,16 @@ class MlsGroupManager(
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.addMember(keyPackageBytes)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
/**
* Remove a member and create a Commit.
* Remove a member and create a Commit. See [addMember] for the
* pre-commit exporter key contract on the returned [CommitResult].
*/
suspend fun removeMember(
nostrGroupId: HexKey,
@@ -378,8 +393,9 @@ class MlsGroupManager(
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.removeMember(targetLeafIndex)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -388,31 +404,22 @@ class MlsGroupManager(
* Rotate the signing key within a group and commit.
*
* Per MIP-00, members SHOULD self-update within 24 hours of joining.
* This creates an Update proposal with a fresh signing key and commits it.
*
* @param nostrGroupId hex-encoded Nostr group ID
* @return the [CommitResult] to publish
* See [addMember] for the pre-commit exporter key contract.
*/
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
group.proposeSigningKeyRotation()
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
/**
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
* Creates the proposal, commits it, and persists the new state.
*
* Authorization (MIP-01): extension updates require admin privileges once
* the group has at least one admin. During bootstrap — before any
* `admin_pubkeys` are configured — any member may seed the initial
* extension set (e.g. the creator installing the first
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with
* themselves as admin).
* See [addMember] for the pre-commit exporter key contract.
*/
suspend fun updateGroupExtensions(
nostrGroupId: HexKey,
@@ -425,9 +432,10 @@ class MlsGroupManager(
check(!adminsConfigured || group.isLocalAdmin()) {
"MIP-01: only admins may update group extensions"
}
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
group.proposeGroupContextExtensions(extensions)
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -561,12 +569,18 @@ class MlsGroupManager(
}
}
private fun retainEpochSecrets(
/**
* Push a previously-captured [RetainedEpochSecrets] into the bounded
* retention window. Call after the epoch advance has been applied
* successfully so that failed commits don't pollute the window with
* duplicate current-epoch keys.
*/
private fun pushRetainedEpoch(
nostrGroupId: HexKey,
group: MlsGroup,
retainedBefore: RetainedEpochSecrets,
) {
val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() }
retained.add(group.retainedSecrets())
retained.add(retainedBefore)
// Trim to retention window (keep only the most recent N-1 epochs)
while (retained.size > EPOCH_RETENTION_WINDOW) {
@@ -662,9 +676,11 @@ class MlsGroupManager(
/**
* Number of past epochs to retain for late-arriving message decryption.
* MLS forward secrecy guarantees mean we want to limit this window.
* Matches MDK's `DEFAULT_EPOCH_LOOKBACK` so a message encrypted under
* the prior N epochs' exporter secrets can still be decrypted after a
* Commit advances the group. Capped for forward-secrecy reasons.
*/
const val EPOCH_RETENTION_WINDOW = 2
const val EPOCH_RETENTION_WINDOW = 5
/** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */
private const val REUSE_GUARD_LENGTH = 4
@@ -91,11 +91,34 @@ data class UpdatePath(
/**
* Result of creating a Commit: the MLS messages to distribute.
*
* [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful
* for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit]
* entry point. For on-the-wire distribution, callers MUST publish
* [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope)
* so that receivers can parse the sender's leaf index and confirmation tag.
*/
data class CommitResult(
val commitBytes: ByteArray,
val welcomeBytes: ByteArray?,
val groupInfoBytes: ByteArray?,
/**
* Fully-framed commit ready for the MIP-03 outer ChaCha20 encryption.
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
*/
val framedCommitBytes: ByteArray = commitBytes,
/**
* `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit**
* epoch (N) — the key the group had when this commit was computed, before
* local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap
* the commit with this key (RFC 9420 §12.4 and MDK parity). Using the
* post-commit (N+1) key makes the commit unreadable to existing members
* still at epoch N — the exact scenario that caused Eden to stall at
* epoch 1 and never decrypt any of David's subsequent messages.
*
* Empty by default for the test-only entry points that don't need it.
*/
val preCommitExporterSecret: ByteArray = ByteArray(0),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -47,9 +47,6 @@ class SecretTree(
private val encryptionSecret: ByteArray,
private val leafCount: Int,
) {
/** Cached tree node secrets, computed lazily */
private val treeSecrets = mutableMapOf<Int, ByteArray>()
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
private val senderState = mutableMapOf<Int, SenderRatchetState>()
@@ -72,11 +69,6 @@ class SecretTree(
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
}
init {
// Seed the root
treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret
}
/**
* Get the next (key, nonce) for application messages from a given sender.
*/
@@ -252,36 +244,51 @@ class SecretTree(
}
/**
* Derive the leaf secret from the encryption secret using the tree structure.
* Derive the leaf secret from the encryption secret by walking DOWN the
* binary tree from the root to the target leaf (RFC 9420 §9).
*
* At each step we pick left or right based on which subtree contains the
* target. In an MLS left-balanced tree the left-subtree node indices are
* always strictly less than the current node, and the right-subtree
* indices strictly greater — so the direction is simply
* `targetNode < currentNode`.
*
* This also correctly handles non-power-of-2 leaf counts (3, 5, 6, 7, 9
* …) where the rightmost leaves live beneath "virtual" intermediate
* nodes that are beyond `nodeCount`. Walking down still succeeds because
* [BinaryTree.left] / [BinaryTree.right] give the correct descendants
* even for virtual nodes, and the target leaf is reachable via a chain
* of left/right steps that mixes real and virtual intermediates.
*
* The previous implementation derived the target's secret from
* `BinaryTree.parent(target)` which, for leaves on the right edge of a
* non-full tree, returned a high ancestor (often the root) that is NOT
* the target's direct parent. Its `left()` and `right()` then pointed
* at different nodes entirely, the target stayed un-cached, and the
* follow-up `return treeSecrets[nodeIndex]!!` either threw NPE (on
* JVM) or — when repeatedly re-entered — recursed into
* [getNodeSecret] until the stack was exhausted (on ART).
*/
private fun getLeafSecret(leafIndex: Int): ByteArray {
val nodeIndex = BinaryTree.leafToNode(leafIndex)
return getNodeSecret(nodeIndex)
}
val targetNode = BinaryTree.leafToNode(leafIndex)
val rootIdx = BinaryTree.root(leafCount)
var currentSecret = encryptionSecret
var currentNode = rootIdx
/**
* Recursively derive a node's secret from its parent in the secret tree.
*/
private fun getNodeSecret(nodeIndex: Int): ByteArray {
treeSecrets[nodeIndex]?.let { return it }
while (currentNode != targetNode) {
val goLeft = targetNode < currentNode
val label = if (goLeft) "left" else "right"
currentSecret =
MlsCryptoProvider.expandWithLabel(
currentSecret,
"tree",
label.encodeToByteArray(),
MlsCryptoProvider.HASH_OUTPUT_LENGTH,
)
currentNode = if (goLeft) BinaryTree.left(currentNode) else BinaryTree.right(currentNode)
}
val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
val parentSecret = getNodeSecret(parentIdx)
// Derive left and right children secrets
val leftIdx = BinaryTree.left(parentIdx)
val rightIdx = BinaryTree.right(parentIdx)
val leftSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "left".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val rightSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "right".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
treeSecrets[leftIdx] = leftSecret
treeSecrets[rightIdx] = rightSecret
// Clear parent secret for forward secrecy
treeSecrets.remove(parentIdx)
return treeSecrets[nodeIndex]!!
return currentSecret
}
}
@@ -0,0 +1,114 @@
/*
* 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.quartz.nip53LiveActivities.clip
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream clip (zap.stream convention, kind 1313).
*
* A clip is a standalone highlight produced from an ongoing or past live stream.
* The event carries:
* - `a` -> the source stream address (kind 30311)
* - `p` -> the stream host's pubkey
* - `r` -> direct playable video URL (MP4/HLS)
* - `title` -> clip title
* - `alt` -> NIP-31 fallback text
*
* `content` is an optional free-text caption.
*/
@Immutable
class LiveActivitiesClipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
fun activity(): ATag? =
tags
.asSequence()
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey)
fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse)
fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse)
companion object {
const val KIND = 1313
const val ALT = "Live activity clip"
/**
* Builds an event template for a clip. Typically published by the clip-authoring backend
* on behalf of a viewer, but can also be published directly by a client.
*/
fun build(
activity: EventHintBundle<LiveActivitiesEvent>,
videoUrl: String,
title: String,
host: HexKey = activity.event.pubKey,
caption: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> Unit = {},
) = eventTemplate(KIND, caption, createdAt) {
aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay))
add(PTag.assemble(host, null))
add(ReferenceTag.assemble(videoUrl))
add(TitleTag.assemble(title))
add(AltTag.assemble(ALT))
initializer()
}
}
}
@@ -0,0 +1,115 @@
/*
* 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.quartz.nip53LiveActivities.raid
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream raid (zap.stream convention, kind 1312).
*
* A raid is authored by the source streamer to redirect viewers to another live
* stream. The event carries two `a` tags referencing NIP-53 Live Activities
* (kind 30311) differentiated by NIP-10-style markers at position 3:
* - "root" -> the source stream (the raid is being sent FROM)
* - "mention" -> the target stream (the raid is being sent TO)
*
* The `content` is a free-text raid message from the source streamer.
*/
@Immutable
class LiveActivitiesRaidEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
fun fromActivity(): ATag? = findActivity(MARKER_ROOT)
fun toActivity(): ATag? = findActivity(MARKER_MENTION)
fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
private fun findActivity(marker: String): ATag? =
tags
.asSequence()
.filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker }
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
companion object {
const val KIND = 1312
const val ALT = "Live activity raid"
const val MARKER_ROOT = "root"
const val MARKER_MENTION = "mention"
/**
* Builds an event template for a raid. Sender must be the host of the `from` stream.
*
* @param from source stream (the one currently live that is being ended/redirected)
* @param to target stream (where viewers should be redirected)
* @param message raid announcement text
*/
fun build(
from: EventHintBundle<LiveActivitiesEvent>,
to: EventHintBundle<LiveActivitiesEvent>,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesRaidEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
addMarkedATag(from, MARKER_ROOT)
addMarkedATag(to, MARKER_MENTION)
initializer()
}
private fun TagArrayBuilder<LiveActivitiesRaidEvent>.addMarkedATag(
bundle: EventHintBundle<LiveActivitiesEvent>,
marker: String,
) {
val relayUrl = bundle.relay?.url.orEmpty()
val addressId =
Address.assemble(
LiveActivitiesEvent.KIND,
bundle.event.pubKey,
bundle.event.dTag(),
)
add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker))
}
}
}
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
/**
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
*/
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
companion object {
const val KIND = 30311
const val ALT = "Live activity event"
const val GOAL_TAG = "goal"
suspend fun create(
signer: NostrSigner,
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
fun TagArrayBuilder<CommunityPostApprovalEvent>.community(event: EventHintBundle<CommunityDefinitionEvent>) = add(event.toATag().toATagArray())
@@ -37,6 +38,4 @@ fun TagArrayBuilder<CommunityPostApprovalEvent>.approved(event: EventHintBundle<
}
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) {
add(event.toETagArray())
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay))
@@ -21,18 +21,22 @@
package com.vitorpamplona.quartz.nip72ModCommunities.definition
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
fun TagArrayBuilder<CommunityDefinitionEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<CommunityDefinitionEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(
webUrl: String,
dimensions: DimensionTag? = null,
) = addUnique(ImageTag.assemble(webUrl, dimensions))
fun TagArrayBuilder<CommunityDefinitionEvent>.rules(rules: String) = addUnique(RulesTag.assemble(rules))
@@ -35,6 +35,10 @@ class RelayTag(
companion object {
const val TAG_NAME = "relay"
const val MARKER_AUTHOR = "author"
const val MARKER_REQUESTS = "requests"
const val MARKER_APPROVALS = "approvals"
fun parse(tag: Array<String>): RelayTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -436,7 +438,9 @@ class EventFactory {
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -37,8 +37,20 @@ class KeyPackageUtilsTest {
private val testPubKey = "a".repeat(64)
private val testRef = "b".repeat(64)
/**
* Per MIP-00 the `d` tag MUST be 64 lowercase hex characters
* (a random 32-byte slot ID). The strict [KeyPackageUtils.isValid] enforces
* this on the parse side, so test fixtures need realistic 64-char hex
* slot IDs even when we only care about the selection logic.
*/
private fun slot(label: Int): String = "0".repeat(63) + label.toString(16)
private val slot0 = slot(0)
private val slot1 = slot(1)
private val slotLastResort = "f".repeat(64)
private fun makeKeyPackageEvent(
dTag: String = "0",
dTag: String = slot0,
createdAt: Long = 1000,
encoding: String = "base64",
ciphersuite: String = "0x0001",
@@ -105,8 +117,8 @@ class KeyPackageUtilsTest {
@Test
fun testSelectBest_PrefersNewest() {
val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000)
val old = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
val newer = makeKeyPackageEvent(dTag = slot1, createdAt = 2000)
val best = KeyPackageUtils.selectBest(listOf(old, newer))
assertNotNull(best)
@@ -115,33 +127,33 @@ class KeyPackageUtilsTest {
@Test
fun testSelectBest_PrefersNonLastResort() {
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
val regular = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
// Even though lastResort is newer, regular is preferred
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr")
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = slotLastResort)
assertNotNull(best)
assertEquals("0", best.dTag())
assertEquals(slot0, best.dTag())
}
@Test
fun testSelectBest_FallsBackToLastResort() {
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
// Only last-resort available
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr")
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = slotLastResort)
assertNotNull(best)
assertEquals("lr", best.dTag())
assertEquals(slotLastResort, best.dTag())
}
@Test
fun testSelectBest_FiltersOutInvalid() {
val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw")
val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500)
val invalid = makeKeyPackageEvent(dTag = slot0, encoding = "raw")
val valid = makeKeyPackageEvent(dTag = slot1, createdAt = 500)
val best = KeyPackageUtils.selectBest(listOf(invalid, valid))
assertNotNull(best)
assertEquals("1", best.dTag())
assertEquals(slot1, best.dTag())
}
@Test
@@ -159,7 +171,7 @@ class KeyPackageUtilsTest {
val template =
KeyPackageUtils.buildRotation(
newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=",
dTagSlot = "0",
dTagSlot = slot0,
newKeyPackageRef = testRef,
relays = emptyList(),
)
@@ -115,27 +115,24 @@ class MarmotMipComplianceTest {
@Test
fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() {
// Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for
// name/description/admins/relays/images, then disappearing_message_secs
// = 8 bytes of zero (invalid).
// Hand-crafted TLS blob (MIP-01 QUIC VarInt length prefixes):
// uint16 version=3 | opaque group_id[32] | 8x empty VarInt(0) fields
// (name..image_upload_key) | disappearing_message_secs = VarInt(8) + 8
// zero bytes (invalid per MIP-01).
val header =
ByteArray(2 + 32) {
// version + groupId
when (it) {
0 -> 0
1 -> 3
// version=3
else -> 0
}
}
// 8x opaque2 fields of length 0, each encoded as two zero bytes:
// 8 empty VarInt-prefixed opaque fields, each a single 0x00 byte:
// name, description, admin_pubkeys, relays, image_hash, image_key,
// image_nonce, image_upload_key
val zeroFields = ByteArray(8 * 2) // all zeros
// disappearing_message_secs opaque2 with 8 zero bytes
val disappearingField = ByteArray(2 + 8).also { it[1] = 8 }
val zeroFields = ByteArray(8) // all 0x00
// disappearing_message_secs: VarInt(8) = 0x08, then 8 zero bytes
val disappearingField = ByteArray(1 + 8).also { it[0] = 0x08 }
val blob = header + zeroFields + disappearingField
// decodeTls catches any exception and returns null
@@ -150,13 +147,124 @@ class MarmotMipComplianceTest {
it[0] = 0
it[1] = 99
}
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
val disappearingField = ByteArray(2) // zero-length
val zeroFields = ByteArray(8) // 8x VarInt(0) for name..image_upload_key
val disappearingField = ByteArray(1) // VarInt(0) — zero-length field
val blob = header + zeroFields + disappearingField
assertNull(MarmotGroupData.decodeTls(blob))
}
@Test
fun marmotGroupData_rejectsDuplicateAdminPubkeys() {
// MIP-01: admin_pubkeys MUST NOT contain duplicate keys.
assertFailsWith<IllegalArgumentException> {
MarmotGroupData(
nostrGroupId = groupId32,
adminPubkeys = listOf(adminPubkey, adminPubkey),
)
}
}
// --- MIP-01 byte-level interop fixtures (MDK reference) ----------------
//
// These fixtures were produced by serializing the identical struct via the
// Rust `tls_codec` 0.4 crate used by MDK (see commit message for the
// generator). They pin Amethyst's v2 encoder output byte-for-byte against
// the MDK reference, so any future regression in VarInt framing surfaces
// immediately.
//
// Fixtures are v2 (no `disappearing_message_secs` field) because MDK's
// current `CURRENT_VERSION = 2` reference has no v3 support yet.
private fun mdkFixtureA(): ByteArray =
(
// version=2 + 32 bytes of group_id (all zero)
"0002" +
"00".repeat(32) +
// name=empty, description=empty (VarInt(0) = single 0x00)
"0000" +
// admin_pubkeys: VarInt(32) = 0x20, then one 32-byte key of 0xAA
"20" + "aa".repeat(32) +
// relays: outer VarInt(21) = 0x15; inner VarInt(20) = 0x14 +
// "wss://relay.example/" (20 bytes)
"15" + "14" + "7773733a2f2f72656c61792e6578616d706c652f" +
// image_hash, image_key, image_nonce, image_upload_key — all empty
"00000000"
).hexToByteArray()
private fun mdkFixtureB(): ByteArray =
(
"0002" +
"11".repeat(32) +
// name: VarInt(8)=0x08 + "Amethyst"
"08" + "416d657468797374" +
// description: VarInt(10)=0x0a + "Test group"
"0a" + "546573742067726f7570" +
// admin_pubkeys: outer VarInt(64) — 64 = 0x40, two-byte VarInt
// prefix "40 40" (high bits 01, value 0x0040) + 2×32 bytes
"4040" + "bb".repeat(32) + "cc".repeat(32) +
// relays outer VarInt(44) = 0x2c, then two inner relays each
// VarInt(21) + 21-byte URL
"2c" +
"15" + "7773733a2f2f72656c6179312e6578616d706c652f" +
"15" + "7773733a2f2f72656c6179322e6578616d706c652f" +
// image_* all empty
"00000000"
).hexToByteArray()
@Test
fun marmotGroupData_encodesFixtureAByteForByteVsMdk() {
// Encode an Amethyst MarmotGroupData with the same inputs and assert the
// bytes match MDK's tls_codec 0.4 output exactly.
val data =
MarmotGroupData(
version = 2,
nostrGroupId = "00".repeat(32),
name = "",
description = "",
adminPubkeys = listOf("aa".repeat(32)),
relays = listOf("wss://relay.example/"),
)
assertContentEquals(mdkFixtureA(), data.encodeTls())
}
@Test
fun marmotGroupData_encodesFixtureBByteForByteVsMdk() {
val data =
MarmotGroupData(
version = 2,
nostrGroupId = "11".repeat(32),
name = "Amethyst",
description = "Test group",
adminPubkeys = listOf("bb".repeat(32), "cc".repeat(32)),
relays = listOf("wss://relay1.example/", "wss://relay2.example/"),
)
assertContentEquals(mdkFixtureB(), data.encodeTls())
}
@Test
fun marmotGroupData_decodesMdkFixtureA() {
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureA()))
assertEquals(2, decoded.version)
assertEquals("00".repeat(32), decoded.nostrGroupId)
assertEquals("", decoded.name)
assertEquals("", decoded.description)
assertEquals(listOf("aa".repeat(32)), decoded.adminPubkeys)
assertEquals(listOf("wss://relay.example/"), decoded.relays)
assertNull(decoded.disappearingMessageSecs)
}
@Test
fun marmotGroupData_decodesMdkFixtureB() {
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureB()))
assertEquals(2, decoded.version)
assertEquals("Amethyst", decoded.name)
assertEquals("Test group", decoded.description)
assertEquals(listOf("bb".repeat(32), "cc".repeat(32)), decoded.adminPubkeys)
assertEquals(listOf("wss://relay1.example/", "wss://relay2.example/"), decoded.relays)
}
@Test
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
val seed = "11".repeat(32).hexToByteArray()
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.clip
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesClipEventTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
private val dummySig = "0".repeat(128)
@Test
fun parsesZapStreamShapedClip() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"),
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
arrayOf("alt", "Live stream clip"),
),
content = "Check this out",
sig = dummySig,
)
val activity = assertNotNull(event.activity())
assertEquals(LiveActivitiesEvent.KIND, activity.kind)
assertEquals(host, activity.pubKeyHex)
assertEquals("stream-d", activity.dTag)
assertEquals(host, event.host())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
assertEquals("Nice moment", event.title())
assertEquals("Check this out", event.content)
}
@Test
fun ignoresClipLackingStreamReference() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
),
content = "",
sig = dummySig,
)
assertNull(event.activity())
assertNull(event.activityAddress())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
}
}
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.raid
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesRaidEventTest {
private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val dummySig = "0".repeat(128)
@Test
fun parsesRootAndMentionAddresses() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"),
arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"),
),
content = "Heading over to stream!",
sig = dummySig,
)
val from = assertNotNull(event.fromAddress())
assertEquals(LiveActivitiesEvent.KIND, from.kind)
assertEquals(sourceHost, from.pubKeyHex)
assertEquals("source-d", from.dTag)
val to = assertNotNull(event.toAddress())
assertEquals(LiveActivitiesEvent.KIND, to.kind)
assertEquals(targetHost, to.pubKeyHex)
assertEquals("target-d", to.dTag)
}
@Test
fun ignoresUnmarkedOrWrongKindATags() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
// Wrong marker
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"),
// Wrong kind
arrayOf("a", "30023:$sourceHost:article", "", "root"),
// No marker at all
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"),
),
content = "",
sig = dummySig,
)
assertNull(event.fromAddress())
assertNull(event.toAddress())
}
}
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.streaming
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LiveActivitiesEventGoalTagTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
private val dummySig = "0".repeat(128)
@Test
fun readsZapStreamGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("title", "My stream"),
arrayOf("goal", goalId),
),
content = "",
sig = dummySig,
)
assertEquals(goalId, event.goalEventId())
}
@Test
fun returnsNullWhenNoGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "stream-d")),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
@Test
fun returnsNullWhenGoalTagEmpty() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("goal", ""),
),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
@@ -225,6 +226,87 @@ class MarmotPipelineTest {
}
}
@Test
fun testAddMemberCommitIsFramedAsPublicMessage() {
// Regression: addMember's outbound commit used to carry the raw Commit TLS
// bytes instead of an MlsMessage(PublicMessage(commit)) envelope, which
// caused receivers to fail parsing with "Unsupported MLS version: …"
// when the first two bytes of the Commit struct were read as the
// MlsMessage version field.
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val group = manager.getGroup(groupId)!!
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
// The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit))
val framed = commitResult.framedCommitBytes
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(framed),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
mlsMessage.wireFormat,
)
val publicMessage =
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsMessage.payload),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT,
publicMessage.contentType,
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER,
publicMessage.sender.senderType,
)
assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit")
// The PublicMessage.content carries the raw Commit struct.
kotlin.test.assertContentEquals(commitResult.commitBytes, publicMessage.content)
}
}
@Test
fun testAddMemberCommitEventDecryptsToFramedMlsMessage() {
// End-to-end variant: the kind:445 content returned by buildCommitEvent,
// when ChaCha20-Poly1305 decrypted with the current exporter key, must
// yield an MlsMessage whose wire format is PUBLIC_MESSAGE (not a raw
// Commit struct).
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val group = manager.getGroup(groupId)!!
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val outbound = MarmotOutboundProcessor(manager)
val outboundResult = outbound.buildCommitEvent(groupId, commitResult.framedCommitBytes)
val event = outboundResult.signedEvent
val exporterKey = manager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey)
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsBytes),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
mlsMessage.wireFormat,
)
}
}
@Test
fun testSubscriptionManagerSyncWithGroupManager() {
runBlocking {
@@ -346,6 +428,450 @@ class MarmotPipelineTest {
}
}
@Test
fun testCreatorDoesNotDoubleApplyOwnCommitAfterRestart() {
// Root cause: after David restarts, his own add-member kind:445 that
// got echoed back from the relay is no longer marked as "already
// processed" (the dedup set lives in memory and isn't persisted).
// The inbound path then outer-decrypts it via a retained epoch key
// and feeds the commit into group.processCommit — which is NOT
// atomic, so it partially advances David's local state before failing
// with "Confirmation tag verification failed" (the tag was computed
// at the original epoch; David is already two epochs past).
//
// After this partial apply, David's epoch / tree / exporter_secret
// drift forward by one epoch while Eden and Fred stay put. Any
// subsequent message David sends uses a key nobody else has, and
// they log "Outer decryption failed with current and N retained
// epoch key(s)".
runBlocking {
val davidStore = TestGroupStateStore()
val davidMgr = MlsGroupManager(davidStore)
val edenMgr = MlsGroupManager(TestGroupStateStore())
val fredMgr = MlsGroupManager(TestGroupStateStore())
val davidId = ByteArray(32) { 0xD1.toByte() }
val edenId = ByteArray(32) { 0xE2.toByte() }
val fredId = ByteArray(32) { 0xF3.toByte() }
davidMgr.createGroup(groupId, davidId)
davidMgr.updateGroupExtensions(
groupId,
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(davidId.toHexKey()),
).toExtension(),
),
)
val davidGroup = davidMgr.getGroup(groupId)!!
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
edenMgr.processCommit(
groupId,
addFred.commitBytes,
davidMgr.getGroup(groupId)!!.leafIndex,
ByteArray(0),
)
val preRestartEpoch = davidMgr.getGroup(groupId)!!.epoch
val preRestartExporter = davidMgr.exporterSecret(groupId)
// Simulate David's app restart.
val davidMgr2 = MlsGroupManager(davidStore)
davidMgr2.restoreAll()
// Now replay David's own echoed add-Eden commit that's still on
// the relay. After restart, the inbound dedup set is empty so
// the inbound pipeline does the full outer-decrypt via retained
// keys + processCommit on an already-applied commit.
val inbound =
com.vitorpamplona.quartz.marmot
.MarmotInboundProcessor(
groupManager = davidMgr2,
keyPackageRotationManager =
com.vitorpamplona.quartz.marmot.mip00KeyPackages
.KeyPackageRotationManager(),
)
val outbound = MarmotOutboundProcessor(davidMgr2)
// Re-encrypt the add-Eden framed commit with the pre-commit key
// so it looks exactly like what the relay would re-deliver.
val echoed =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addEden.framedCommitBytes,
exporterKey = addEden.preCommitExporterSecret,
)
val result = inbound.processGroupEvent(echoed.signedEvent)
assertIs<GroupEventResult.Duplicate>(
result,
"echoed already-applied commit must be treated as a no-op Duplicate",
)
val postEchoEpoch = davidMgr2.getGroup(groupId)!!.epoch
val postEchoExporter = davidMgr2.exporterSecret(groupId)
assertEquals(
preRestartEpoch,
postEchoEpoch,
"processing our own already-applied commit echo must NOT advance the local epoch",
)
kotlin.test.assertContentEquals(
preRestartExporter,
postEchoExporter,
"processing our own already-applied commit echo must NOT change the exporter secret",
)
}
}
@Test
fun testCreatorCanSendMessageAfterRestart() {
// Reproduces production symptom: David creates a group, adds Eden and
// Fred, sends messages — all fine. After David restarts the app
// (simulated here by save-state + fresh MlsGroupManager + restoreAll),
// his outbound kind:445 outer ChaCha20 layer suddenly uses a
// different key than Eden/Fred, and they fail with
// "Outer decryption failed with current and N retained epoch key(s)".
runBlocking {
val davidStore = TestGroupStateStore()
val davidMgr = MlsGroupManager(davidStore)
val edenMgr = MlsGroupManager(TestGroupStateStore())
val fredMgr = MlsGroupManager(TestGroupStateStore())
val davidId = ByteArray(32) { 0xD1.toByte() }
val edenId = ByteArray(32) { 0xE2.toByte() }
val fredId = ByteArray(32) { 0xF3.toByte() }
davidMgr.createGroup(groupId, davidId)
davidMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(davidId.toHexKey()),
).toExtension(),
),
)
val davidGroup = davidMgr.getGroup(groupId)!!
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
// David adds Eden.
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
// David adds Fred; Eden processes Alice's add-Fred commit.
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
edenMgr.processCommit(
nostrGroupId = groupId,
commitBytes = addFred.commitBytes,
senderLeafIndex = davidMgr.getGroup(groupId)!!.leafIndex,
confirmationTag = ByteArray(0),
)
// Pre-restart sanity: all three share the same outer exporter key.
val preRestartDavidKey = davidMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(
preRestartDavidKey,
edenMgr.exporterSecret(groupId),
"Eden's exporter key should match David's before restart",
)
kotlin.test.assertContentEquals(
preRestartDavidKey,
fredMgr.exporterSecret(groupId),
"Fred's exporter key should match David's before restart",
)
// Simulate David's app restart: drop his in-memory manager and
// rebuild it from the persisted store.
val davidMgr2 = MlsGroupManager(davidStore)
davidMgr2.restoreAll()
val postRestartDavidKey = davidMgr2.exporterSecret(groupId)
kotlin.test.assertContentEquals(
preRestartDavidKey,
postRestartDavidKey,
"David's exporter key must survive a save+restore round-trip; " +
"otherwise Eden/Fred can't decrypt his next outer layer.",
)
// David sends a message post-restart; Eden and Fred must decrypt.
val outbound = MarmotOutboundProcessor(davidMgr2)
val msg = "hi after restart"
val postRestartEvent =
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
val edenKey = edenMgr.exporterSecret(groupId)
val mlsBytesEden = GroupEventEncryption.decrypt(postRestartEvent.signedEvent.content, edenKey)
val edenDecrypted = edenMgr.decrypt(groupId, mlsBytesEden)
assertEquals(msg, edenDecrypted.content.decodeToString())
}
}
@Test
fun testFredEncryptsAfterJoiningThreeMemberGroup() {
// Reproduces the production StackOverflowError: the last-joined member
// (Fred at leafIndex=2 in a 3-member group) attempts to encrypt his
// first message and the SecretTree.getNodeSecret recursion never
// terminates.
runBlocking {
val aliceMgr = createGroupManager()
val bobMgr = createGroupManager()
val fredMgr = createGroupManager()
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
aliceMgr.createGroup(groupId, aliceIdBytes)
aliceMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
).toExtension(),
),
)
val aliceGroup = aliceMgr.getGroup(groupId)!!
val bobBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xB2.toByte() }, ByteArray(0))
val fredBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xF3.toByte() }, ByteArray(0))
// Alice adds Bob.
val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle)
// Alice adds Fred (he joins at leafIndex=2, in a 3-leaf tree).
val addFred = aliceMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
// Bob (existing member at the pre-add-Fred epoch) receives and
// applies Alice's add-Fred commit to reach the same epoch as
// Alice + Fred. Without this, Bob can't decrypt Fred's subsequent
// application messages.
bobMgr.processCommit(
nostrGroupId = groupId,
commitBytes = addFred.commitBytes,
senderLeafIndex = aliceMgr.getGroup(groupId)!!.leafIndex,
confirmationTag = ByteArray(0),
)
// Sanity: Fred is at leafIndex=2, leafCount=3.
assertEquals(2, fredMgr.getGroup(groupId)!!.leafIndex)
// This is where production crashed with StackOverflowError.
val fredMsg = "hi from fred"
val fredCiphertext = fredMgr.encrypt(groupId, fredMsg.encodeToByteArray())
// And Alice & Bob must be able to decrypt it.
val aliceDecrypted = aliceMgr.decrypt(groupId, fredCiphertext)
assertEquals(fredMsg, aliceDecrypted.content.decodeToString())
val bobDecrypted = bobMgr.decrypt(groupId, fredCiphertext)
assertEquals(fredMsg, bobDecrypted.content.decodeToString())
}
}
@Test
fun testAddMemberExposesPreCommitExporterSecret() {
// Regression: before the fix, MarmotManager.addMember outer-encrypted
// the kind:445 commit with the POST-commit (epoch N+1) exporter key,
// which meant existing members at epoch N couldn't decrypt it. The
// fix captures the pre-commit key inside MlsGroup.commit() and ships
// it back via CommitResult.preCommitExporterSecret so MarmotManager
// can pass it to the outbound builder.
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val preEpochExporter = manager.exporterSecret(groupId)
val preEpoch = manager.getGroup(groupId)!!.epoch
val bobBundle =
manager
.getGroup(groupId)!!
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val result = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
// Local state advanced to N+1.
assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch)
// But the returned pre-commit exporter secret matches what the
// group held BEFORE advancing — the key existing members still at
// epoch N are holding.
kotlin.test.assertContentEquals(
preEpochExporter,
result.preCommitExporterSecret,
)
// The post-commit exporter (what groupManager.exporterSecret now
// returns) MUST differ — otherwise we haven't actually rotated.
kotlin.test.assertFalse(
manager.exporterSecret(groupId).contentEquals(preEpochExporter),
"post-commit exporter key must differ from the pre-commit one",
)
assertNotNull(result.welcomeBytes)
}
}
@Test
fun testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage() {
// End-to-end regression for the David/Eden/Fred scenario: a group
// creator (Alice) adds two members (Bob then Carol) in sequence and
// then publishes an application message. Both members MUST be able to
// decrypt the message. Before the stage/merge fix, the add-Carol
// commit was outer-encrypted with the post-commit (epoch 2) key,
// leaving Bob stuck at epoch 1 and unable to decrypt any of Alice's
// subsequent application messages.
runBlocking {
val aliceMgr = createGroupManager()
val bobMgr = createGroupManager()
val carolMgr = createGroupManager()
// Use 32-byte identities so they fit the MarmotGroupData
// admin_pubkeys 32-byte slots.
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
val bobIdBytes = ByteArray(32) { 0xB2.toByte() }
val carolIdBytes = ByteArray(32) { 0xC3.toByte() }
aliceMgr.createGroup(groupId, aliceIdBytes)
// Install the Marmot group data extension so the Welcome carries
// the NostrGroupData (MlsGroupManager.processWelcome requires it).
aliceMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
).toExtension(),
),
)
val aliceGroup = aliceMgr.getGroup(groupId)!!
// KeyPackages are just MLS artifacts carrying identity + init key;
// createKeyPackage on any group instance produces a usable bundle.
val bobBundle = aliceGroup.createKeyPackage(bobIdBytes, ByteArray(0))
val carolBundle = aliceGroup.createKeyPackage(carolIdBytes, ByteArray(0))
val outbound = MarmotOutboundProcessor(aliceMgr)
// --- Step 1: Alice adds Bob. CommitResult carries the pre-commit
// exporter secret so buildCommitEvent can outer-encrypt with
// the epoch-N (pre-Bob) key that Bob-less Alice held. ---
val addBobResult = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val addBobCommitEvent =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addBobResult.framedCommitBytes,
exporterKey = addBobResult.preCommitExporterSecret,
)
// Bob joins via Welcome (emulated: we hand him the Welcome bytes).
bobMgr.processWelcome(addBobResult.welcomeBytes!!, bobBundle)
val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch
assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch)
// --- Step 2: Alice adds Carol. addCarolResult.preCommitExporterSecret
// is the epoch-N (2-member) key that Bob still holds. ---
val addCarolResult = aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes())
val addCarolCommitEvent =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addCarolResult.framedCommitBytes,
exporterKey = addCarolResult.preCommitExporterSecret,
)
// Carol joins via Welcome at the new epoch.
carolMgr.processWelcome(addCarolResult.welcomeBytes!!, carolBundle)
// *** The key assertion ***: Bob (still at epoch 1) receives the
// add-Carol kind:445 and must be able to:
// (a) outer-decrypt using his current (epoch 1) exporter key,
// (b) parse the MlsMessage → PublicMessage → COMMIT,
// (c) apply the commit and advance to epoch 2.
val bobExporterAtE1 = bobMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(
bobExporterAtE1,
addCarolResult.preCommitExporterSecret,
"Bob's current exporter (pre-add-Carol) must match the " +
"pre-commit key used to outer-encrypt the add-Carol commit; " +
"otherwise Bob cannot decrypt and will be stuck on the old epoch.",
)
val decryptedMlsBytes =
GroupEventEncryption.decrypt(
addCarolCommitEvent.signedEvent.content,
bobExporterAtE1,
)
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(decryptedMlsBytes),
)
val publicMessage =
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsMessage.payload),
)
bobMgr.processCommit(
nostrGroupId = groupId,
commitBytes = publicMessage.content,
senderLeafIndex = publicMessage.sender.leafIndex,
confirmationTag = publicMessage.confirmationTag!!,
)
val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch
assertEquals(epochAfterAddBob + 1, epochAfterAddCarol)
assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch)
assertEquals(epochAfterAddCarol, carolMgr.getGroup(groupId)!!.epoch)
// --- Step 3: Alice sends an application message. Both members must decrypt. ---
val msg = "Hi from Alice"
val appEvent =
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
val bobKey = bobMgr.exporterSecret(groupId)
val carolKey = carolMgr.exporterSecret(groupId)
val aliceKey = aliceMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(aliceKey, bobKey, "Bob must share Alice's epoch-2 key")
kotlin.test.assertContentEquals(aliceKey, carolKey, "Carol must share Alice's epoch-2 key")
val bobMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, bobKey)
val bobDecrypted = bobMgr.decrypt(groupId, bobMlsBytes)
assertEquals(msg, bobDecrypted.content.decodeToString())
val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey)
val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes)
assertEquals(msg, carolDecrypted.content.decodeToString())
// --- Alice also receives her own echo (as the app does via the
// relay round-trip); this exercises the secretTree-based decrypt
// path with myLeafIndex != sender or when sentKeys misses.
val aliceMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, aliceKey)
val aliceDecrypted = aliceMgr.decrypt(groupId, aliceMlsBytes)
assertEquals(msg, aliceDecrypted.content.decodeToString())
// Send a second and third message to stress the secretTree ratchet
// and ensure getNodeSecret stays terminating across generations.
for (i in 1..3) {
val m = "msg-$i"
val ev = outbound.buildGroupEventFromBytes(groupId, m.encodeToByteArray())
val keyNow = aliceMgr.exporterSecret(groupId)
val bytes = GroupEventEncryption.decrypt(ev.signedEvent.content, keyNow)
val dec = bobMgr.decrypt(groupId, bytes)
assertEquals(m, dec.content.decodeToString())
}
}
}
@Test
fun testFullRoundtripEncryptDecrypt() {
runBlocking {
@@ -225,9 +225,11 @@ class MlsGroupEdgeCaseTest {
// 6. Multiple epochs of encrypt/decrypt
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — commit_secret decryption from
// UpdatePath does not correctly derive matching epoch secrets between commit()
// and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions.
// BUG: same empty-commit (no proposals, pure UpdatePath) divergence as
// MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The
// SecretTree.getNodeSecret non-full-tree fix doesn't address this — after
// 5 empty commits Alice and Bob's ratchet secrets diverge and AEAD
// decryption fails with "Tag mismatch". Tracked separately.
@Ignore
@Test
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
@@ -242,7 +244,6 @@ class MlsGroupEdgeCaseTest {
bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0))
}
assertEquals(7L, alice.epoch) // epoch 0 + addMember(1) + 5 commits = 6... wait
// epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6
assertEquals(6L, alice.epoch)
assertEquals(alice.epoch, bob.epoch)
@@ -176,7 +176,6 @@ class MlsGroupLifecycleTest {
// because the commit_secret decryption from the UpdatePath does not
// correctly walk the ratchet tree to find the common ancestor's path secret.
// This causes AEAD decryption failures on cross-member messages.
@Ignore
@Test
fun testThreeMemberGroup_SequentialAdditions() {
// Alice creates the group
@@ -243,7 +242,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -266,7 +264,6 @@ class MlsGroupLifecycleTest {
}
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testExternalJoin_ExporterSecretsAgree() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -312,7 +309,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testSigningKeyRotation_EpochAdvances() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -342,7 +338,6 @@ class MlsGroupLifecycleTest {
}
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testEncryptDecryptAfterSigningKeyRotation() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -412,7 +407,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testPskProposal_EpochAdvancesWithPsk() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -472,7 +466,11 @@ class MlsGroupLifecycleTest {
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
// BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and
// Bob's exporter secrets after processCommit. Unrelated to the SecretTree
// non-full-tree fix — the other @Ignored "processCommit diverges" tests in
// this file and MlsGroupEdgeCaseTest now pass, but this one still fails.
// Tracked separately.
@Ignore
@Test
fun testEmptyCommit_AdvancesEpoch() {
+125 -9
View File
@@ -175,21 +175,45 @@ jq_group_id() {
# wait_for_invite <B|C> <timeout-seconds>
# Echoes the first new group_id that appears in `wn groups invites --json`.
# Returns 1 on timeout.
#
# Also prints a heartbeat every ~10s with:
# - elapsed/remaining time
# - current count of pending welcomes (should be 0 until it isn't)
# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error)
# so a stalled poll gives the operator something to forward.
wait_for_invite() {
local who="$1" timeout="${2:-60}" deadline gid
deadline=$(( $(date +%s) + timeout ))
local who="$1" timeout="${2:-60}" deadline gid start last_hb
local wnfn data_dir
if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR"
else wnfn=wn_c; data_dir="$C_DIR"; fi
start=$(date +%s)
deadline=$(( start + timeout ))
last_hb=$start
while [[ $(date +%s) -lt $deadline ]]; do
if [[ "$who" == "B" ]]; then
gid=$(wn_b_json groups invites 2>/dev/null \
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
else
gid=$(wn_c_json groups invites 2>/dev/null \
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
fi
gid=$("$wnfn" --json groups invites 2>/dev/null \
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
if [[ -n "${gid:-}" ]]; then
printf '%s\n' "$gid"
return 0
fi
# Heartbeat every ~10s.
local now=$(date +%s)
if (( now - last_hb >= 10 )); then
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
local pending
pending=$("$wnfn" --json groups invites 2>/dev/null \
| jq 'length' 2>/dev/null || echo "?")
local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
| grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \
| tail -n 1 || true)
fi
info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending tail: ${recent:-<no relevant wnd log yet>}"
last_hb=$now
fi
sleep 2
done
return 1
@@ -297,6 +321,98 @@ load_state() {
grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2-
}
# ------- daemon diagnostics --------------------------------------------------
# Dumps everything we know about a daemon's state. Called before polling for
# invites (baseline) and on every invite-poll failure so the operator can
# forward a single log file that explains what the daemon saw (or didn't).
#
# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward).
dump_daemon_diagnostics() {
local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex
if [[ "$who" == "B" ]]; then
data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b
npub="${B_NPUB:-?}"; hex="${B_HEX:-?}"
else
data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c
npub="${C_NPUB:-?}"; hex="${C_HEX:-?}"
fi
printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2
printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE"
info "$who identity npub=$npub"
info "$who identity hex =$hex"
info "$who socket $socket"
# Relay list grouped by type — this is the critical bit for invite
# debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only,
# so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these
# URLs, the gift wrap will never reach B.
step "$who relays (per type):"
for t in nip65 inbox key_package; do
local raw urls
raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true)
urls=$(printf '%s' "$raw" \
| jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \
| paste -sd',' -)
info " $t: ${urls:-<none>}"
printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE"
done
# Connection status per relay — shows which sockets are actually live.
step "$who relay connection status:"
"$wnfn" --json relays list 2>/dev/null \
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
| tee -a "$LOG_FILE" >&2 || true
# Daemon-level subscription health.
step "$who wn debug health:"
local health
health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true)
printf ' %s\n' "${health:-<no output>}" | tee -a "$LOG_FILE" >&2
# Relay-control state (subscriptions, filters, planes). Truncated to keep
# stderr readable; full copy still goes to $LOG_FILE.
step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):"
local rcs
rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true)
printf '%s\n' "${rcs:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$LOG_FILE"
# Pending welcomes already decrypted by the daemon. Non-empty here while
# wait_for_invite is polling would be a race worth noticing.
step "$who pending invites:"
local inv
inv=$("$wnfn" --json groups invites 2>/dev/null || true)
printf ' %s\n' "${inv:-<no output>}" | tee -a "$LOG_FILE" >&2
# Tail of daemon stderr — contains the live 1059/welcome processing logs.
step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):"
if [[ -f "$data_dir/logs/stderr.log" ]]; then
tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true
else
info " (no stderr log at $data_dir/logs/stderr.log)"
fi
printf '%s%s---- end %s diagnostics ----%s\n\n' \
"$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2
printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
}
# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log
# ends up with both sides of the pipe in one place.
prompt_amethyst_logcat() {
local note="${1:-paste below}"
prompt_human "On the host running adb, capture Amethyst's Marmot logs:
adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\
| tail -n 200 > /tmp/amethyst-marmot.log
Then paste /tmp/amethyst-marmot.log contents here so the failure report has
both the Amethyst-side publish log and the wn-side subscription log ($note)."
}
# ------- group cleanup -------------------------------------------------------
cleanup_group() {
local gid="$1" who="${2:-B}"
+30 -3
View File
@@ -318,16 +318,30 @@ test_01_keypackage_discovery() {
test_02_amethyst_creates_group() {
banner "Test 02 — Amethyst creates group, invites B"
# Baseline dump BEFORE the human triggers the publish in Amethyst.
# If this diagnostic shows B has no inbox relays (or the inbox relays
# differ from the ones Amethyst has cached for B's kind:10050 list),
# the welcome gift wrap will never reach B no matter how correctly
# Amethyst sends it.
dump_daemon_diagnostics B "pre-invite baseline"
prompt_human "In Amethyst:
1. Tap '+' -> Create Group
2. Name: Interop-02
3. Add member: $B_NPUB
4. Tap Create / Send Invite"
4. Tap Create / Send Invite
step "polling B's daemon for invite (60s)"
Tip: watch the Android logcat for tag 'MarmotDbg' — you should see
'publishing welcome gift wrap id=… kind:1059 → N relay(s): [...]' listing
the same relays as B's inbox above. If the list is empty or different,
that's the bug."
step "polling B's daemon for invite (60s, heartbeat every ~10s)"
local gid
if ! gid=$(wait_for_invite B 60); then
fail_msg "no invite arrived at B"
dump_daemon_diagnostics B "post-timeout (test_02)"
prompt_amethyst_logcat "test_02 timeout"
record_result "02 Amethyst->B create+invite" fail "invite never arrived"
return
fi
@@ -431,14 +445,18 @@ test_04_three_member_group() {
fi
info "using group $gid"
dump_daemon_diagnostics C "pre-invite baseline (test_04)"
prompt_human "In Amethyst, open the group from Test 02 (or 'Interop-04-bootstrap').
Group Info -> Add Member -> paste: $C_NPUB
Confirm the invite is sent."
step "polling C for invite (60s)"
step "polling C for invite (60s, heartbeat every ~10s)"
local c_gid
if ! c_gid=$(wait_for_invite C 60); then
fail_msg "C never received invite"
dump_daemon_diagnostics C "post-timeout (test_04)"
prompt_amethyst_logcat "test_04 timeout"
record_result "04 3-member add-after-create" fail "invite to C missing"
return
fi
@@ -865,6 +883,15 @@ main() {
ensure_identity C
prompt_for_a_npub
configure_relays
# Baseline dump after relays are configured but before any tests run.
# This is the single most useful log to forward when Test 02 fails —
# it shows whether B's kind:10050 (inbox) relay list actually landed
# on the relays Amethyst will look at for the giftwrap delivery target.
banner "Post-configure baseline diagnostics"
dump_daemon_diagnostics B "post-configure"
dump_daemon_diagnostics C "post-configure"
instruct_amethyst_setup
test_01_keypackage_discovery