diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8e9f39da6..956685e7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -547,17 +547,17 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } - val defaultHomeFollowList = async { parseOrNull(defaultHomeFollowListStr) ?: TopFilter.AllFollows } - val defaultStoriesFollowList = async { parseOrNull(defaultStoriesFollowListStr) ?: TopFilter.Global } - val defaultNotificationFollowList = async { parseOrNull(defaultNotificationFollowListStr) ?: TopFilter.Global } - val defaultDiscoveryFollowList = async { parseOrNull(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(defaultPollsFollowListStr) ?: TopFilter.Global } - val defaultPicturesFollowList = async { parseOrNull(defaultPicturesFollowListStr) ?: TopFilter.Global } - val defaultProductsFollowList = async { parseOrNull(defaultProductsFollowListStr) ?: TopFilter.AroundMe } - val defaultShortsFollowList = async { parseOrNull(defaultShortsFollowListStr) ?: TopFilter.Global } - val defaultLongsFollowList = async { parseOrNull(defaultLongsFollowListStr) ?: TopFilter.Global } - val defaultArticlesFollowList = async { parseOrNull(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(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 parseOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e26f783b2..1baaaaffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = topNavFilterFlow(settings.defaultBrowseEmojiSetsFollowList) val liveBrowseEmojiSetsFollowListsPerRelay = OutboxLoaderState(liveBrowseEmojiSetsFollowLists, cache, scope).flow + val liveCommunitiesFollowLists: StateFlow = 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, + image: String? = null, + rules: String? = null, + relays: List? = 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 { val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey)) val newEvent = newNote?.event as? ProfileBadgesEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f7f217508..51c12ed88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -192,6 +192,7 @@ class AccountSettings( val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 8512b8d51..ac8d998f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 002b2935f..b1e9c9466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 37cb82cc3..7a4bbeb1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -31,6 +31,7 @@ class UiSettingsFlow( val preferredLanguage: MutableStateFlow = MutableStateFlow(null), val automaticallyShowImages: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), val automaticallyStartPlayback: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), + val automaticallyPlayVideos: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyShowUrlPreview: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), val automaticallyHideNavigationBars: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyShowProfilePictures: MutableStateFlow = 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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index c2f573f41..c899fcbdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 6d8cf5382..0a219cd6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 99f0a5556..e2c164850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -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 = 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt new file mode 100644 index 000000000..0b5f200ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -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(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, +) { + 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 8a1ad6380..6a3fb5539 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -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 { val choices = mutableListOf() 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() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt new file mode 100644 index 000000000..f62ba4353 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 0252d4835..5c21ecaf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index ccf493ab0..3a1027fd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index e4bed1081..19128180c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 22ec78ba2..0c104ee61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 29253e02b..438dd451a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 62aa4e196..357426917 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { CommunitiesScreen(accountViewModel, nav) } + composableFromEnd { NewCommunityScreen(accountViewModel, nav) } + composableFromEndArgs { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index be455d8ae..8c28d3caf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 73e6b4b55..279bc10da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index cc178d3e6..dc2c775bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a869d042b..8f007e18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 68f928f2c..0ece66d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 81725b40a..f61ddf1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index d008ad627..7c502ce92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index 70b9bb0d8..d6177d0c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -138,5 +138,7 @@ class UiSettingsState( fun startVideoPlayback() = startVideoPlayback.value + fun autoPlayVideos() = uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS + fun showImages() = showImages.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index b2defe162..f573affbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 9e24c48ed..100760c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e2d5fac54..d01995e59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt new file mode 100644 index 000000000..bb5a8f39e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt new file mode 100644 index 000000000..5838ff092 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt new file mode 100644 index 000000000..967ca1ec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -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, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 99b5e86a0..2dc1d6932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 706e10f64..ea2fe7886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -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, + relayActivity: Map, + 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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index a12f41959..aa67b6bf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index ea531c1bd..e8231e71b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -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() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 6fab0c332..0daf59b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler( since: SincePerRelayMap?, ): List? = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt new file mode 100644 index 000000000..427101e5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -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", ""]`. + * We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it. + */ +fun filterGoalForLiveActivities( + channel: LiveActivitiesChannel, + since: SincePerRelayMap?, +): List { + 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, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 6ce98ef3b..c1c47325a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index e84895170..344403048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt new file mode 100644 index 000000000..794281aa1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt @@ -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)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt new file mode 100644 index 000000000..439ef3cb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -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`. + */ +@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 create(modelClass: Class): 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)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 8e02c4945..cf2228a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 932e3197f..e826250c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt new file mode 100644 index 000000000..7071950a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt new file mode 100644 index 000000000..5c8957f05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt new file mode 100644 index 000000000..a16490b90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt new file mode 100644 index 000000000..3b5decf1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt @@ -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 { + 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): Set = innerApplyFilter(newItems) + + override fun innerApplyFilter(collection: Collection): Set { + 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 = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt new file mode 100644 index 000000000..1ac72e047 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt @@ -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() { + val group = + listOf( + CommunitiesListSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt index 25581490c..a1db05935 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt new file mode 100644 index 000000000..61cef800a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: CommunitiesListQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt new file mode 100644 index 000000000..12593aae0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt @@ -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, + since: SincePerRelayMap?, +): List { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt new file mode 100644 index 000000000..09c61bed8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -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(null) + + // Image upload state - mirrors NewBadgeModel. + var multiOrchestrator by mutableStateOf(null) + var selectedServer by mutableStateOf(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() + val relays = mutableStateListOf() + + 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) { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt new file mode 100644 index 000000000..456a577c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -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) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index fe246d8b6..c1478ea52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -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(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index 93170f9d3..ae247c5dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -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, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 66f06d75e..69f439936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 0e943641d..bcb315079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index ace4f154d..5e7c8caca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -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 = diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 473b78618..1b4e6d170 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -386,6 +386,37 @@ Ankiety Otwarte Zamknięte + Odznaki + Otrzymano + Moje + Przyznane + Odkryj + Nowa odznaka + Edytuj odznakę + Znak wyróżnienia + Zaakceptuj + Odrzuć + Usuń z profilu + ID odznaki (d-tag) + Nazwa + Nazwa odznaki czytelna dla człowieka + Opis + Za co przyznawana jest ta odznaka? + Adres URL obrazu (1024×1024) + https://domena.pl/plakietka.png + URL miniatury (opcjonalnie) + https://domena.pl/miniatura-plakietki.png + Wgraj obraz + Wybierz kwadratowe zdjęcie, które posłuży jako wizerunek Twojej odznaki. + Ładowanie odznaki… + Szukaj użytkowników + Nazwa, npub lub NIP-05 + Usuń + Przyznano dla %1$d + Otrzymałeś odznakę + Odznaki · %1$d + Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu. + Nie otrzymałeś jeszcze żadnych odznak. Zdjęcia Filmiki Filmy wideo @@ -400,6 +431,8 @@ Twoje przypięte notatki Przypnij do profilu Odepnij z profilu + Usuń z listy + Odrzuć Listy zakładek Ikona listy zakładek Nowa lista zakładek @@ -412,6 +445,8 @@ Zobacz linki Zobacz Hashtagi Nie masz jeszcze żadnych list zakładek. Naciśnij nowy przycisk poniżej, aby utworzyć listę. + Zmień nazwę + Sklonuj Prywatne posty Prywatnych postów: (%1$s) Publiczne posty @@ -1486,7 +1521,15 @@ Lokalizacje Społeczności Listy + Algorytmy kanału + Wszystkie ulubione algorytmy kanału Transmitery + Dodaj algorytm kanału do ulubionych + Usuń z ulubionych + Przetwarzanie statusu kanału… + Ten algorytm kanału jest płatny + Algorytm kanału zwrócił błąd + Ponów Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3692563a7..331435854 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -271,6 +271,7 @@ 因此聊天信息会随着时间的推移而消失。 公共聊天 MLS 组 + 尚无消息 公开聊天元数据 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 @@ -390,6 +391,26 @@ 开启 已关闭 徽章 + 社区 + 新建社区 + 编辑社区 + 社区名 + 描述 + 规则 (可选) + 添加封面图像 + 轻触选取一张照片 — 它将上传到您的媒体服务器。 + 协管 + 协管可以批准帖子。您始终是协管。 + 添加协管 + 姓名, npub, 或 NIP-05 + 所有者 + 中继 + 选择托管请求、批准或社区作者元数据的中继。 + 任何 + 作者 + 请求 + 批准 + 没有社区匹配此过滤器。 已收到 我的 授予的 @@ -452,6 +473,20 @@ 查看链接 查看话题标签 您还没有任何书签列表。轻按下面的新建按钮制作一个。 + 兴趣集 + 您还没有任何兴趣集。轻按下面的新建按钮制作一个。 + 新的兴趣集 + 新的兴趣集 + 集合名称 + 我的兴趣 + 添加话题标签 + 私密 + %1$d 个话题标签 + 兴趣集操作 + 重命名 + 克隆 + 添加话题标签 + 切换公共/私密 私密帖子 私密帖子(%1$s) 公开帖子 @@ -954,6 +989,7 @@ 主题 图像预览 视频播放 + 自动播放视频 URL 预览 沉浸式滚动 滚动时隐藏导航栏 @@ -1024,6 +1060,7 @@ 暗色、亮色或系统主题 自动加载图像和 GIF 自动播放视频和 GIF + 视频在屏幕上可见时自动播放视频 显示 URL 预览 何时加载图像 复制 Stack @@ -1525,6 +1562,7 @@ 选择一个用于过滤订阅源的列表 话题标签 + 兴趣集 位置 社区 列表 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e8eceb895..83d963062 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -53,6 +53,10 @@ You are using a public key and public keys are read-only. 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 like posts No Zap Amount Setup. Long Press to change + zapped %1$s sats + Anonymous + is raiding + created a clip You are using a public key and public keys are read-only. 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 follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow @@ -284,6 +288,7 @@ Public Chat MLS Group + No messages yet Public Chat Metadata 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 @@ Open Closed Badges + Communities + New Community + Edit Community + Community name + Description + Rules (optional) + Add a cover image + Tap to pick a photo — it will be uploaded to your media server. + Moderators + Moderators can approve posts. You are always a moderator. + Add a moderator + Name, npub, or NIP-05 + Owner + Relays + Pick relays that will host requests, approvals, or the community author\'s metadata. + Any + Author + Requests + Approvals + No communities match this filter. Received Mine Awarded @@ -1092,6 +1117,7 @@ Theme Image Preview Video Playback + Autoplay Videos URL Preview Immersive Scrolling Hide Nav Bars when Scrolling @@ -1183,7 +1209,8 @@ For the App\'s Interface Dark, Light or System theme Automatically load images and GIFs - Automatically plays videos and GIFs + Automatically load videos and GIFs + Automatically play videos when visible on screen Show URL previews When to load images diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt new file mode 100644 index 000000000..52355902d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt @@ -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, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 00673dcef..a7410e557 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -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 --- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index bf9ec65a0..85a857f7f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -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>(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>> = WeakReference(null) fun changesFlow(): MutableSharedFlow> { @@ -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 { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt new file mode 100644 index 000000000..2d0e88564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt @@ -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, + limit: Int = DEFAULT_LIMIT, + ): List { + if (limit <= 0) return emptyList() + + // receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e. + val unique = HashMap() + for (c in contributions) { + unique[c.receiptId] = c + } + if (unique.isEmpty()) return emptyList() + + val totals = HashMap(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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt new file mode 100644 index 000000000..032c53a75 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -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` 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() + private val goalContributions = HashMap() + + private val _topZappers = MutableStateFlow>(emptyList()) + val topZappers: StateFlow> = _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): 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" } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt new file mode 100644 index 000000000..f1d64c7e1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 92725aa91..1160561d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index df3cecc28..3f0e276cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -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 = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt index 8d9e7a790..fb961e36e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index e326bbcf3..3d89264be 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -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> — 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 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 `` 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 + * opaque description + * opaque admin_pubkeys // concatenated 32-byte keys + * RelayUrl relays // VarInt-length-prefixed UTF-8 strings + * opaque image_hash + * opaque image_key + * opaque image_nonce + * opaque image_upload_key + * opaque disappearing_message_secs // 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() 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() 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 -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt index da717ab1e..029b23479 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt index fcc9284e8..9fdd5c6eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt index 8cad85a6e..1d51545f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - initializer() - } + fun build(createdAt: Long = TimeUtils.now()) = eventTemplate(KIND, "", createdAt) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt index 6eca9b6f8..5edbadf60 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt @@ -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): TokenTagData? { ensure(tag.has(3) && tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt index 246f05910..db6b9e638 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt @@ -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 + // 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 = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index cc24c4deb..a8d99382e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 422fd0ad2..7fddab22b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt index 6bf3e753d..624dd8fff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt index 3766a396f..5c3c28e66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt @@ -47,9 +47,6 @@ class SecretTree( private val encryptionSecret: ByteArray, private val leafCount: Int, ) { - /** Cached tree node secrets, computed lazily */ - private val treeSecrets = mutableMapOf() - /** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */ private val senderState = mutableMapOf() @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt new file mode 100644 index 000000000..32bb28397 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints(): List = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys(): List = 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, + videoUrl: String, + title: String, + host: HexKey = activity.event.pubKey, + caption: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> 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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt new file mode 100644 index 000000000..cbf9f605e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = 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, + to: EventHintBundle, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + addMarkedATag(from, MARKER_ROOT) + addMarkedATag(to, MARKER_MENTION) + initializer() + } + + private fun TagArrayBuilder.addMarkedATag( + bundle: EventHintBundle, + 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)) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index bf710592a..67754e328 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -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", ""]` 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt index 3b55ac56b..ecedc922c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt @@ -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.community(event: EventHintBundle) = add(event.toATag().toATagArray()) @@ -37,6 +38,4 @@ fun TagArrayBuilder.approved(event: EventHintBundle< } } -fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) { - add(event.toETagArray()) -} +fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt index 0fa5adefe..c5d5ed0d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt @@ -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.name(name: String) = addUnique(NameTag.assemble(name)) fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) -fun TagArrayBuilder.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl)) +fun TagArrayBuilder.image( + webUrl: String, + dimensions: DimensionTag? = null, +) = addUnique(ImageTag.assemble(webUrl, dimensions)) fun TagArrayBuilder.rules(rules: String) = addUnique(RulesTag.assemble(rules)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 6398f4b55..bda3d1141 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -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): RelayTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8c46307f2..c22b0e799 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt index e7d7e0e59..9bbe5a861 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt @@ -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(), ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt index f5c7be6c0..201f79e63 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt @@ -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 { + 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() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt new file mode 100644 index 000000000..473d8374e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt new file mode 100644 index 000000000..25b02addb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt new file mode 100644 index 000000000..20ce31fa6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt @@ -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()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index 62b9c77d9..a819ad36e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -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( + 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 { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt index 4647c3960..d77ff1e89 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt @@ -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) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt index c16a0256e..3a829233d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt @@ -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() { diff --git a/tools/marmot-interop/lib.sh b/tools/marmot-interop/lib.sh index aaa04bfae..4194d3e24 100644 --- a/tools/marmot-interop/lib.sh +++ b/tools/marmot-interop/lib.sh @@ -175,21 +175,45 @@ jq_group_id() { # wait_for_invite # 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:-}" + 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:-}" + 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:-}" | 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:-}" | head -n 20 | sed 's/^/ /' >&2 + printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-}" >>"$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:-}" | 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}" diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index a7c60e96f..132ec3012 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -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