mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge latest main into emoji feature branch
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt
This commit is contained in:
@@ -547,17 +547,17 @@ object LocalPreferences {
|
||||
|
||||
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
|
||||
|
||||
val defaultHomeFollowList = async { parseOrNull<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
|
||||
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
|
||||
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
|
||||
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
|
||||
val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) }
|
||||
val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) }
|
||||
val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) }
|
||||
val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) }
|
||||
|
||||
val defaultPollsFollowList = async { parseOrNull<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
|
||||
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
|
||||
val defaultProductsFollowList = async { parseOrNull<TopFilter>(defaultProductsFollowListStr) ?: TopFilter.AroundMe }
|
||||
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
|
||||
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
|
||||
val defaultArticlesFollowList = async { parseOrNull<TopFilter>(defaultArticlesFollowListStr) ?: TopFilter.AllFollows }
|
||||
val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) }
|
||||
val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) }
|
||||
val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) }
|
||||
val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) }
|
||||
val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) }
|
||||
val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) }
|
||||
|
||||
val nwcWalletsLoaded =
|
||||
async {
|
||||
@@ -672,6 +672,20 @@ object LocalPreferences {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseTopFilterOrDefault(
|
||||
value: String?,
|
||||
default: TopFilter,
|
||||
): TopFilter {
|
||||
if (value.isNullOrEmpty() || value == "null") return default
|
||||
return try {
|
||||
JsonMapper.fromJson<TopFilter>(value)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding TopFilter from Preferences with value $value", e)
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
|
||||
@@ -224,6 +224,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
@@ -486,6 +488,9 @@ class Account(
|
||||
val liveBrowseEmojiSetsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBrowseEmojiSetsFollowList)
|
||||
val liveBrowseEmojiSetsFollowListsPerRelay = OutboxLoaderState(liveBrowseEmojiSetsFollowLists, cache, scope).flow
|
||||
|
||||
val liveCommunitiesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCommunitiesFollowList)
|
||||
val liveCommunitiesFollowListsPerRelay = OutboxLoaderState(liveCommunitiesFollowLists, cache, scope).flow
|
||||
|
||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||
|
||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||
@@ -1166,6 +1171,34 @@ class Account(
|
||||
client.publish(signedEvent, relays)
|
||||
}
|
||||
|
||||
suspend fun sendCommunityDefinition(
|
||||
name: String,
|
||||
description: String,
|
||||
moderators: List<ModeratorTag>,
|
||||
image: String? = null,
|
||||
rules: String? = null,
|
||||
relays: List<RelayTag>? = null,
|
||||
dTag: String,
|
||||
): CommunityDefinitionEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val template =
|
||||
CommunityDefinitionEvent.build(
|
||||
name = name,
|
||||
description = description,
|
||||
moderators = moderators,
|
||||
image = image,
|
||||
rules = rules,
|
||||
relays = relays,
|
||||
dTag = dTag,
|
||||
)
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
|
||||
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
||||
val newEvent = newNote?.event as? ProfileBadgesEvent
|
||||
|
||||
@@ -192,6 +192,7 @@ class AccountSettings(
|
||||
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
||||
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
@@ -467,6 +468,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultCommunitiesFollowList(name: FeedDefinition) {
|
||||
changeDefaultCommunitiesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultCommunitiesFollowList(name: TopFilter) {
|
||||
if (defaultCommunitiesFollowList.value != name) {
|
||||
defaultCommunitiesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
|
||||
changeDefaultPicturesFollowList(name.code)
|
||||
}
|
||||
|
||||
@@ -169,9 +169,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
@@ -1495,6 +1497,44 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LiveActivitiesRaidEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val fromAddress = event.fromAddress()
|
||||
val toAddress = event.toAddress()
|
||||
if (fromAddress == null && toAddress == null) return false
|
||||
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
if (new) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
|
||||
toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LiveActivitiesClipEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val activityAddress = event.activityAddress() ?: return false
|
||||
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
if (new) {
|
||||
val channel = getOrCreateLiveChannel(activityAddress)
|
||||
val note = getOrCreateNote(event.id)
|
||||
channel.addNote(note, relay)
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(
|
||||
event: ChannelHideMessageEvent,
|
||||
@@ -1515,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Already processed this event.
|
||||
if (note.event != null) return false
|
||||
|
||||
// Already processed this event — still ensure it's routed into any live-activity
|
||||
// channel(s) it references. A zap that was first consumed by, e.g., the notifications
|
||||
// subscription must still appear in the stream's chat when the user opens the stream.
|
||||
if (note.event != null) {
|
||||
attachZapToLiveActivityChannel(event, note, relay)
|
||||
return false
|
||||
}
|
||||
|
||||
if (wasVerified || justVerify(event)) {
|
||||
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
|
||||
@@ -1541,6 +1587,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
repliesTo.forEach { it.addZap(zapRequest, note) }
|
||||
|
||||
attachZapToLiveActivityChannel(event, note, relay)
|
||||
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
return true
|
||||
@@ -1549,6 +1597,27 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun attachZapToLiveActivityChannel(
|
||||
event: LnZapEvent,
|
||||
note: Note,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
// Match zap.stream: only show zaps whose receiver is the live activity host.
|
||||
val hosts = event.zappedAuthor().toHashSet()
|
||||
if (hosts.isEmpty()) return
|
||||
|
||||
// Route into every live-activity address this zap references (zap.stream uses one, but
|
||||
// a receipt could legitimately reference multiple simulcasted streams).
|
||||
event.tags
|
||||
.asSequence()
|
||||
.mapNotNull(ATag::parseAddress)
|
||||
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts }
|
||||
.distinct()
|
||||
.forEach { address ->
|
||||
getOrCreateLiveChannel(address).addNote(note, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LnZapRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2647,6 +2716,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesClipEvent -> consume(event, relay, wasVerified)
|
||||
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
@@ -31,6 +31,7 @@ data class UiSettings(
|
||||
val preferredLanguage: String? = null,
|
||||
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyPlayVideos: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
|
||||
@@ -31,6 +31,7 @@ class UiSettingsFlow(
|
||||
val preferredLanguage: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
val automaticallyShowImages: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
|
||||
val automaticallyStartPlayback: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
|
||||
val automaticallyPlayVideos: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val automaticallyShowUrlPreview: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
|
||||
val automaticallyHideNavigationBars: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val automaticallyShowProfilePictures: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
|
||||
@@ -46,6 +47,7 @@ class UiSettingsFlow(
|
||||
preferredLanguage,
|
||||
automaticallyShowImages,
|
||||
automaticallyStartPlayback,
|
||||
automaticallyPlayVideos,
|
||||
automaticallyShowUrlPreview,
|
||||
automaticallyHideNavigationBars,
|
||||
automaticallyShowProfilePictures,
|
||||
@@ -64,14 +66,15 @@ class UiSettingsFlow(
|
||||
flows[1] as String?,
|
||||
flows[2] as ConnectivityType,
|
||||
flows[3] as ConnectivityType,
|
||||
flows[4] as ConnectivityType,
|
||||
flows[5] as BooleanType,
|
||||
flows[6] as ConnectivityType,
|
||||
flows[7] as Boolean,
|
||||
flows[4] as BooleanType,
|
||||
flows[5] as ConnectivityType,
|
||||
flows[6] as BooleanType,
|
||||
flows[7] as ConnectivityType,
|
||||
flows[8] as Boolean,
|
||||
flows[9] as FeatureSetType,
|
||||
flows[10] as ProfileGalleryType,
|
||||
flows[11] as BooleanType,
|
||||
flows[9] as Boolean,
|
||||
flows[10] as FeatureSetType,
|
||||
flows[11] as ProfileGalleryType,
|
||||
flows[12] as BooleanType,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +84,7 @@ class UiSettingsFlow(
|
||||
preferredLanguage.value,
|
||||
automaticallyShowImages.value,
|
||||
automaticallyStartPlayback.value,
|
||||
automaticallyPlayVideos.value,
|
||||
automaticallyShowUrlPreview.value,
|
||||
automaticallyHideNavigationBars.value,
|
||||
automaticallyShowProfilePictures.value,
|
||||
@@ -110,6 +114,10 @@ class UiSettingsFlow(
|
||||
automaticallyStartPlayback.tryEmit(torSettings.automaticallyStartPlayback)
|
||||
any = true
|
||||
}
|
||||
if (automaticallyPlayVideos.value != torSettings.automaticallyPlayVideos) {
|
||||
automaticallyPlayVideos.tryEmit(torSettings.automaticallyPlayVideos)
|
||||
any = true
|
||||
}
|
||||
if (automaticallyShowUrlPreview.value != torSettings.automaticallyShowUrlPreview) {
|
||||
automaticallyShowUrlPreview.tryEmit(torSettings.automaticallyShowUrlPreview)
|
||||
any = true
|
||||
@@ -165,6 +173,7 @@ class UiSettingsFlow(
|
||||
MutableStateFlow(uiSettings.preferredLanguage),
|
||||
MutableStateFlow(uiSettings.automaticallyShowImages),
|
||||
MutableStateFlow(uiSettings.automaticallyStartPlayback),
|
||||
MutableStateFlow(uiSettings.automaticallyPlayVideos),
|
||||
MutableStateFlow(uiSettings.automaticallyShowUrlPreview),
|
||||
MutableStateFlow(uiSettings.automaticallyHideNavigationBars),
|
||||
MutableStateFlow(uiSettings.automaticallyShowProfilePictures),
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+8
-8
@@ -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,
|
||||
|
||||
+7
@@ -26,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -50,6 +52,7 @@ fun VideoViewInner(
|
||||
controllerVisible: MutableState<Boolean> = mutableStateOf(false),
|
||||
onZoom: (() -> Unit)? = null,
|
||||
hasBlurhash: Boolean = false,
|
||||
isFullscreen: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
@@ -71,6 +74,10 @@ fun VideoViewInner(
|
||||
mediaItem = mediaItem,
|
||||
muted = muted,
|
||||
) { controller ->
|
||||
ApplyInitialVideoQuality(
|
||||
player = controller.controller,
|
||||
policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST,
|
||||
)
|
||||
VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
|
||||
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
|
||||
RenderVideoPlayer(
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
|
||||
/**
|
||||
* Which HLS rendition to pick automatically the first time tracks become available for a media
|
||||
* item. Expressed as an explicit policy instead of a `Boolean` so call sites must commit to one
|
||||
* value at construction and any future dynamic-state caller is forced to key the effect on it.
|
||||
*/
|
||||
enum class VideoQualityPolicy {
|
||||
/** Lock to the lowest-resolution rendition (feed and PiP: save bandwidth). */
|
||||
LOWEST,
|
||||
|
||||
/** Clear any video override so the player uses adaptive bitrate (fullscreen). */
|
||||
AUTO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a default video quality when tracks become available on the given player.
|
||||
*
|
||||
* The initial selection is applied once per media item. If the user later changes the quality
|
||||
* manually, or swaps to a different media item, the new choice wins — we don't reapply for the
|
||||
* same media id. Selections intentionally don't persist across composable lifecycles, so opening
|
||||
* a feed video in fullscreen starts with [VideoQualityPolicy.AUTO] and returning to the feed
|
||||
* starts with [VideoQualityPolicy.LOWEST] again.
|
||||
*/
|
||||
@Composable
|
||||
fun ApplyInitialVideoQuality(
|
||||
player: Player,
|
||||
policy: VideoQualityPolicy,
|
||||
) {
|
||||
// Tracks the media id we've already initialized so we don't fight user overrides after the
|
||||
// first application.
|
||||
val appliedForMediaId = remember(player) { mutableStateOf<String?>(null) }
|
||||
|
||||
DisposableEffect(player, policy) {
|
||||
// Re-arm the guard whenever the player or the policy changes so a new policy gets a
|
||||
// chance to apply even if the same media id has already been handled under the old one.
|
||||
appliedForMediaId.value = null
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
applyInitialQuality(player, tracks, policy, appliedForMediaId)
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks might already be available by the time we attach the listener.
|
||||
applyInitialQuality(player, player.currentTracks, policy, appliedForMediaId)
|
||||
player.addListener(listener)
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// Invoked from Player.Listener callbacks and DisposableEffect bodies, both of which run on
|
||||
// the player's application looper (main thread for ExoPlayer). The body mutates Compose state
|
||||
// and trackSelectionParameters; both are main-thread-only.
|
||||
@MainThread
|
||||
private fun applyInitialQuality(
|
||||
player: Player,
|
||||
tracks: Tracks,
|
||||
policy: VideoQualityPolicy,
|
||||
appliedForMediaId: MutableState<String?>,
|
||||
) {
|
||||
val mediaId = player.currentMediaItem?.mediaId ?: return
|
||||
if (appliedForMediaId.value == mediaId) return
|
||||
|
||||
val videoGroup = getVideoTrackGroup(tracks) ?: return
|
||||
// No point forcing a choice when there's only one rendition, and no future update will
|
||||
// change that for this media id, so mark it as settled.
|
||||
if (videoGroup.length <= 1) {
|
||||
appliedForMediaId.value = mediaId
|
||||
return
|
||||
}
|
||||
|
||||
when (policy) {
|
||||
VideoQualityPolicy.AUTO -> {
|
||||
if (hasVideoOverride(player)) clearVideoOverride(player)
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
|
||||
VideoQualityPolicy.LOWEST -> {
|
||||
// If no supported track has a positive short side yet, leave the guard unset so we
|
||||
// retry on the next onTracksChanged when real video dimensions arrive.
|
||||
val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return
|
||||
selectVideoTrack(player, videoGroup, lowestIndex)
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-27
@@ -51,10 +51,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -119,6 +118,21 @@ fun VideoQualityButton(
|
||||
}
|
||||
|
||||
if (openDialog) {
|
||||
// Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every
|
||||
// rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open.
|
||||
var videoSize by remember(player) { mutableStateOf(player.videoSize) }
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onVideoSizeChanged(newSize: VideoSize) {
|
||||
videoSize = newSize
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 }
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
onDismissRequest = { openDialog = false },
|
||||
@@ -126,10 +140,10 @@ fun VideoQualityButton(
|
||||
) {
|
||||
VideoQualityChoices(
|
||||
videoGroup = videoGroup,
|
||||
currentShortSide = getCurrentPlayingShortSide(tracks),
|
||||
currentShortSide = currentShortSide,
|
||||
isAuto = !hasVideoOverride(player),
|
||||
onSelectAuto = {
|
||||
clearVideoOverride(player)
|
||||
if (hasVideoOverride(player)) clearVideoOverride(player)
|
||||
openDialog = false
|
||||
},
|
||||
onSelectTrack = { trackIndex ->
|
||||
@@ -195,6 +209,7 @@ private data class QualityChoice(
|
||||
private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> {
|
||||
val choices = mutableListOf<QualityChoice>()
|
||||
for (i in 0 until group.length) {
|
||||
if (!group.isTrackSupported(i)) continue
|
||||
val format = group.getTrackFormat(i)
|
||||
val shortSide = minOf(format.width, format.height)
|
||||
if (shortSide > 0) {
|
||||
@@ -210,26 +225,3 @@ private fun formatBitrate(bitrate: Int): String =
|
||||
bitrate >= 1_000_000 -> String.format(Locale.US, "%.1f Mbps", bitrate / 1_000_000.0)
|
||||
else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO }
|
||||
|
||||
private fun clearVideoOverride(player: Player) {
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun selectVideoTrack(
|
||||
player: Player,
|
||||
group: Tracks.Group,
|
||||
trackIndex: Int,
|
||||
) {
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackIndex))
|
||||
.build()
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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()
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+2
@@ -570,6 +570,7 @@ private fun RenderImageOrVideo(
|
||||
automaticallyStartPlayback = true,
|
||||
controllerVisible = controllerVisible,
|
||||
hasBlurhash = content.blurhash != null,
|
||||
isFullscreen = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -630,6 +631,7 @@ private fun RenderImageOrVideo(
|
||||
automaticallyStartPlayback = true,
|
||||
controllerVisible = controllerVisible,
|
||||
hasBlurhash = content.blurhash != null,
|
||||
isFullscreen = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ object ScrollStateKeys {
|
||||
const val POLLS_CLOSED = "PollsClosedFeed"
|
||||
const val BADGES_SCREEN = "BadgesFeed"
|
||||
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
|
||||
const val COMMUNITIES_LIST = "CommunitiesListFeed"
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||
const val SHORTS_SCREEN = "ShortsFeed"
|
||||
|
||||
+8
-2
@@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -90,12 +92,16 @@ fun DisappearingScaffold(
|
||||
// The outer Surface provides the Material container color + onBackground as
|
||||
// LocalContentColor, matching M3 Scaffold's behaviour (without it, default text
|
||||
// color falls back to Color.Black and is invisible on the dark theme).
|
||||
val rootModifier =
|
||||
val baseModifier =
|
||||
if (allowBarHide) {
|
||||
Modifier.imePadding().nestedScroll(connection)
|
||||
} else {
|
||||
Modifier.imePadding()
|
||||
}
|
||||
val rootModifier =
|
||||
baseModifier
|
||||
.let { if (topBar == null) it.statusBarsPadding() else it }
|
||||
.let { if (bottomBar == null) it.navigationBarsPadding() else it }
|
||||
|
||||
Surface(
|
||||
modifier = rootModifier,
|
||||
@@ -210,7 +216,7 @@ private fun FloatingButtonHolder(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.graphicsLayer {
|
||||
val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0f)
|
||||
val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0.001f)
|
||||
scaleX = visible
|
||||
scaleY = visible
|
||||
alpha = visible
|
||||
|
||||
@@ -93,6 +93,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||
@@ -227,6 +230,9 @@ fun BuildNavigation(
|
||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Communities> { CommunitiesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NewCommunity> { NewCommunityScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditCommunity> { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
|
||||
+9
@@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Groups
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material.icons.outlined.Photo
|
||||
@@ -636,6 +637,14 @@ fun ListContent(
|
||||
route = Route.Badges,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.communities,
|
||||
icon = Icons.Outlined.Groups,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Communities,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.discover_marketplace,
|
||||
icon = Icons.Outlined.Storefront,
|
||||
|
||||
@@ -45,6 +45,22 @@ sealed class Route {
|
||||
|
||||
@Serializable object Polls : Route()
|
||||
|
||||
@Serializable object Communities : Route()
|
||||
|
||||
@Serializable object NewCommunity : Route()
|
||||
|
||||
@Serializable data class EditCommunity(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Badges : Route()
|
||||
|
||||
@Serializable object ProfileBadges : Route()
|
||||
|
||||
+5
-1
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -351,7 +352,10 @@ fun RenderOption(
|
||||
is CommunityName -> {
|
||||
val it by observeNote(option.note, accountViewModel)
|
||||
|
||||
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
val addressable = it.note as? AddressableNote
|
||||
val definition = addressable?.event as? CommunityDefinitionEvent
|
||||
val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: ""
|
||||
Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
is RelayName -> {
|
||||
|
||||
@@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is LiveActivitiesClipEvent -> {
|
||||
RenderChatClip(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is FhirResourceEvent -> {
|
||||
RenderFhirResource(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+23
-11
@@ -24,9 +24,12 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableTextColor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -34,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun DisplayFollowingCommunityInPost(
|
||||
@@ -42,38 +46,46 @@ fun DisplayFollowingCommunityInPost(
|
||||
nav: INav,
|
||||
) {
|
||||
Column(HalfStartPadding) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayCommunity(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val communityTag = note.event?.communityAddress() ?: return
|
||||
|
||||
val communityNote = LocalCache.getOrCreateAddressableNote(communityTag)
|
||||
val communityState by observeNote(communityNote, accountViewModel)
|
||||
val label = communityShortLabel(communityTag, communityState.note.event as? CommunityDefinitionEvent)
|
||||
|
||||
ClickableTextColor(
|
||||
getCommunityShortName(communityTag),
|
||||
label,
|
||||
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
) {
|
||||
nav.nav {
|
||||
note.event?.communityAddress()?.let { communityTag ->
|
||||
Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)
|
||||
note.event?.communityAddress()?.let { addr ->
|
||||
Route.Community(addr.kind, addr.pubKeyHex, addr.dTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommunityShortName(communityAddress: Address): String {
|
||||
val name =
|
||||
if (communityAddress.dTag.length > 10) {
|
||||
communityAddress.dTag.take(10) + "..."
|
||||
private fun communityShortLabel(
|
||||
address: Address,
|
||||
definition: CommunityDefinitionEvent?,
|
||||
): String {
|
||||
val raw = definition?.name()?.ifBlank { null } ?: address.dTag
|
||||
val shortened =
|
||||
if (raw.length > 12) {
|
||||
raw.take(12) + "..."
|
||||
} else {
|
||||
communityAddress.dTag.take(10)
|
||||
raw
|
||||
}
|
||||
|
||||
return "/n/$name"
|
||||
return "/n/$shortened"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
@@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
@@ -439,6 +441,9 @@ private fun LongCommunityActionOptions(
|
||||
nav: INav,
|
||||
) {
|
||||
Row {
|
||||
if (note.author?.pubkeyHex == accountViewModel.account.signer.pubKey) {
|
||||
EditCommunityButton(note, nav)
|
||||
}
|
||||
ShareCommunityButton(accountViewModel, note, nav)
|
||||
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
|
||||
if (isFollowing) {
|
||||
@@ -448,6 +453,22 @@ private fun LongCommunityActionOptions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditCommunityButton(
|
||||
note: AddressableNote,
|
||||
nav: INav,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = { nav.nav(Route.EditCommunity(note.address)) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Edit,
|
||||
modifier = Size18Modifier,
|
||||
contentDescription = stringRes(R.string.edit_community),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAddressableNoteFollows(
|
||||
note: AddressableNote,
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -275,6 +276,18 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _communityRoutes =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
@@ -302,6 +315,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val communityRoutes =
|
||||
_communityRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
fun destroy() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
@@ -364,7 +382,11 @@ class PeopleListName(
|
||||
class CommunityName(
|
||||
val note: AddressableNote,
|
||||
) : Name() {
|
||||
override fun name() = "/n/${(note.dTag())}"
|
||||
override fun name(): String {
|
||||
val definition = note.event as? CommunityDefinitionEvent
|
||||
val label = definition?.name()?.ifBlank { null } ?: note.dTag()
|
||||
return "/n/$label"
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
|
||||
@@ -138,5 +138,7 @@ class UiSettingsState(
|
||||
|
||||
fun startVideoPlayback() = startVideoPlayback.value
|
||||
|
||||
fun autoPlayVideos() = uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS
|
||||
|
||||
fun showImages() = showImages.value
|
||||
}
|
||||
|
||||
+19
@@ -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)
|
||||
|
||||
+22
@@ -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}" }
|
||||
}
|
||||
|
||||
+29
-14
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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)
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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,
|
||||
|
||||
+140
-5
@@ -21,15 +21,24 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
@@ -54,18 +63,29 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -84,6 +104,7 @@ fun MarmotGroupInfoScreen(
|
||||
val groupDescription by chatroom.description.collectAsStateWithLifecycle()
|
||||
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
|
||||
val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
|
||||
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
|
||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||
var isLeaving by remember { mutableStateOf(false) }
|
||||
@@ -154,11 +175,11 @@ fun MarmotGroupInfoScreen(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (groupRelays.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Relays: ${groupRelays.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
GroupRelayList(
|
||||
relayUrls = groupRelays,
|
||||
relayActivity = relayActivity,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
|
||||
@@ -338,3 +359,117 @@ fun LeaveGroupDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
|
||||
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun GroupRelayList(
|
||||
relayUrls: List<String>,
|
||||
relayActivity: Map<NormalizedRelayUrl, Long>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val normalized =
|
||||
remember(relayUrls) {
|
||||
relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } }
|
||||
}
|
||||
|
||||
if (normalized.isEmpty()) return
|
||||
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(
|
||||
text = "Relays",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000L
|
||||
normalized.forEach { (raw, relay) ->
|
||||
val lastSeen = relayActivity[relay]
|
||||
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
|
||||
GroupRelayTile(
|
||||
relay = relay,
|
||||
fallbackUrl = raw,
|
||||
isActive = isActive,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GroupRelayTile(
|
||||
relay: NormalizedRelayUrl,
|
||||
fallbackUrl: String,
|
||||
isActive: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relayInfo by loadRelayInfo(relay)
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val clickableModifier =
|
||||
remember(relay) {
|
||||
Modifier
|
||||
.size(55.dp)
|
||||
.combinedClickable(
|
||||
indication = ripple24dp,
|
||||
interactionSource = MutableInteractionSource(),
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboardManager.setText(relay.url)
|
||||
}
|
||||
},
|
||||
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = clickableModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
RenderRelayIcon(
|
||||
displayUrl = relayInfo.id ?: fallbackUrl,
|
||||
iconUrl = relayInfo.icon,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
pingInMs = 0,
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
iconModifier = LargeRelayIconModifier,
|
||||
)
|
||||
|
||||
val dotColor =
|
||||
if (isActive) {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.placeholderText
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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,
|
||||
|
||||
+18
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? =
|
||||
when (val channel = key.channel) {
|
||||
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since)
|
||||
is PublicChatChannel -> filterMessagesToPublicChat(channel, since)
|
||||
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since)
|
||||
else -> null
|
||||
is EphemeralChatChannel -> {
|
||||
filterMessagesToEphemeralChat(channel, since)
|
||||
}
|
||||
|
||||
is PublicChatChannel -> {
|
||||
filterMessagesToPublicChat(channel, since)
|
||||
}
|
||||
|
||||
is LiveActivitiesChannel -> {
|
||||
filterMessagesToLiveActivities(channel, since) +
|
||||
filterGoalForLiveActivities(channel, since)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun id(key: ChannelQueryState) = key.channel
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
|
||||
/**
|
||||
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
|
||||
* that count toward it.
|
||||
*
|
||||
* zap.stream attaches a goal to a 30311 event via `["goal", "<hex event id>"]`.
|
||||
* We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it.
|
||||
*/
|
||||
fun filterGoalForLiveActivities(
|
||||
channel: LiveActivitiesChannel,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val goalId = channel.info?.goalEventId() ?: return emptyList()
|
||||
|
||||
return channel.relays().toSet().flatMap { relay ->
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GoalEvent.KIND),
|
||||
ids = listOf(goalId),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
tags = mapOf("e" to listOf(goalId)),
|
||||
limit = 200,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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))
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches
|
||||
* zap.stream's TopZappers look: pill chips with avatar + lightning + sats.
|
||||
*
|
||||
* State is owned by [LiveStreamTopZappersViewModel], which maintains the aggregation
|
||||
* incrementally off the UI thread and publishes a stable `List<TopZapperEntry>`.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveStreamTopZappers(
|
||||
channel: LiveActivitiesChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val topZappersVm: LiveStreamTopZappersViewModel =
|
||||
viewModel(
|
||||
key = "TopZappers-${channel.address.toValue()}",
|
||||
factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = LiveStreamTopZappersViewModel(channel) as T
|
||||
},
|
||||
)
|
||||
|
||||
// Track the goal note lifecycle — pass it to the VM as it resolves, clear on channel change.
|
||||
val goalId = channel.info?.goalEventId()
|
||||
if (goalId != null) {
|
||||
var goalNoteHolder by remember(goalId) {
|
||||
mutableStateOf(accountViewModel.getNoteIfExists(goalId))
|
||||
}
|
||||
LaunchedEffect(goalId) {
|
||||
if (goalNoteHolder == null) {
|
||||
goalNoteHolder = accountViewModel.checkGetOrCreateNote(goalId)
|
||||
}
|
||||
topZappersVm.setGoalNote(goalNoteHolder)
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(channel) { topZappersVm.setGoalNote(null) }
|
||||
}
|
||||
|
||||
val entries by topZappersVm.topZappers.collectAsStateWithLifecycle()
|
||||
|
||||
if (entries.isEmpty()) return
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
) {
|
||||
items(entries, key = { it.bucketKey }) { entry ->
|
||||
ZapperPill(entry, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapperPill(
|
||||
entry: TopZapperEntry,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val clickModifier =
|
||||
if (entry.isAnonymous) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clickable { nav.nav(Route.Profile(entry.bucketKey)) }
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = clickModifier,
|
||||
) {
|
||||
Row(
|
||||
// Min height matches the avatar so text-only chips (Anonymous) stay aligned
|
||||
// with the avatar chips in the horizontal row.
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = Size24dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (entry.isAnonymous) {
|
||||
Text(
|
||||
text = stringRes(R.string.chat_zap_anonymous),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
} else {
|
||||
UserPicture(
|
||||
userHex = entry.bucketKey,
|
||||
size = Size24dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
ZapIcon(Size16Modifier, BitcoinOrange)
|
||||
Text(
|
||||
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(Modifier.padding(start = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+180
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.model.mapNotNullIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
|
||||
class CommunitiesFeedFilter(
|
||||
account: Account,
|
||||
) : DiscoverCommunityFeedFilter(account) {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-communities-" + followList().code
|
||||
|
||||
override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value
|
||||
|
||||
private fun myPubkey(): String = account.userProfile().pubkeyHex
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = myPubkey()
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note ->
|
||||
val noteEvent = note.event
|
||||
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
followLists = account.liveCommunitiesFollowLists.value,
|
||||
hiddenUsers = account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
val notes =
|
||||
LocalCache.addressables.mapNotNullIntoSet(CommunityDefinitionEvent.KIND) { key, note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) {
|
||||
note
|
||||
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
|
||||
note
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = myPubkey()
|
||||
return collection
|
||||
.filterTo(HashSet()) {
|
||||
val noteEvent = it.event
|
||||
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
|
||||
}
|
||||
}
|
||||
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
followLists = account.liveCommunitiesFollowLists.value,
|
||||
hiddenUsers = account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
return collection
|
||||
.mapNotNull { note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
|
||||
listOf(note)
|
||||
} else if (noteEvent is CommunityPostApprovalEvent) {
|
||||
noteEvent.communityAddresses().mapNotNull {
|
||||
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
|
||||
val definitionEvent = definitionNote.event
|
||||
|
||||
if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) {
|
||||
definitionNote
|
||||
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent, definitionNote.relays)) {
|
||||
definitionNote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun shouldInclude(
|
||||
aTag: Address?,
|
||||
params: FilterByListParams,
|
||||
comingFrom: List<NormalizedRelayUrl> = emptyList(),
|
||||
) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class CommunitiesListQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class CommunitiesListFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<CommunitiesListQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
CommunitiesListSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+25
-18
@@ -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)
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CommunitiesListSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<CommunitiesListQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<CommunitiesListQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: CommunitiesListQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val listName = key.listName()
|
||||
val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled()
|
||||
|
||||
return if (listName == TopFilter.Mine) {
|
||||
val outbox = key.account.outboxRelays.flow.value
|
||||
filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since)
|
||||
} else {
|
||||
makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince)
|
||||
}
|
||||
}
|
||||
|
||||
override fun user(key: CommunitiesListQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: CommunitiesListQueryState) = key.listName()
|
||||
|
||||
fun CommunitiesListQueryState.listNameFlow() = account.settings.defaultCommunitiesFollowList
|
||||
|
||||
fun CommunitiesListQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun CommunitiesListQueryState.followsPerRelayFlow() = account.liveCommunitiesFollowListsPerRelay
|
||||
|
||||
fun CommunitiesListQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: CommunitiesListQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.communitiesList.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
|
||||
private const val COMMUNITIES_MINE_LIMIT = 300
|
||||
|
||||
fun filterCommunitiesMine(
|
||||
pubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
|
||||
val authors = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(CommunityDefinitionEvent.KIND),
|
||||
authors = authors,
|
||||
limit = COMMUNITIES_MINE_LIMIT,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Immutable
|
||||
data class CommunityRelayEntry(
|
||||
val url: NormalizedRelayUrl,
|
||||
val marker: String? = null,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class NewCommunityModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
// When set, publish() reuses this d-tag so the kind-34550 replaceable event
|
||||
// is updated in place (edit flow). Null in the create flow.
|
||||
var existingDTag: String? = null
|
||||
|
||||
var isPublishing by mutableStateOf(false)
|
||||
|
||||
var name by mutableStateOf("")
|
||||
var description by mutableStateOf("")
|
||||
var rules by mutableStateOf("")
|
||||
var existingImageUrl by mutableStateOf<String?>(null)
|
||||
|
||||
// Image upload state - mirrors NewBadgeModel.
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Moderator/Relay lists - backed by mutableStateListOf for direct Compose observation.
|
||||
val moderators = mutableStateListOf<User>()
|
||||
val relays = mutableStateListOf<CommunityRelayEntry>()
|
||||
|
||||
fun init(account: Account) {
|
||||
if (this.account == account) return
|
||||
this.account = account
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads the form with the current contents of [existing] so the user can edit
|
||||
* the replaceable kind 34550 event. Keeps the original `d` tag so relays replace
|
||||
* the previous version instead of creating a new community.
|
||||
*/
|
||||
fun loadFrom(existing: CommunityDefinitionEvent) {
|
||||
existingDTag = existing.dTag()
|
||||
name = existing.name().orEmpty()
|
||||
description = existing.description().orEmpty()
|
||||
rules = existing.rules().orEmpty()
|
||||
existingImageUrl = existing.image()?.imageUrl
|
||||
|
||||
val ownerKey = account?.signer?.pubKey
|
||||
moderators.clear()
|
||||
existing
|
||||
.moderatorKeys()
|
||||
.asSequence()
|
||||
.filter { it != ownerKey }
|
||||
.distinct()
|
||||
.forEach { pubkey ->
|
||||
val user = account?.cache?.getOrCreateUser(pubkey) ?: return@forEach
|
||||
if (moderators.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||
moderators.add(user)
|
||||
}
|
||||
}
|
||||
|
||||
relays.clear()
|
||||
existing.relays().forEach { tag ->
|
||||
if (relays.none { it.url == tag.url }) {
|
||||
relays.add(CommunityRelayEntry(tag.url, tag.marker))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isEditing(): Boolean = existingDTag != null
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
|
||||
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
|
||||
}
|
||||
|
||||
fun hasPickedImage(): Boolean = multiOrchestrator != null
|
||||
|
||||
fun addModerator(user: User) {
|
||||
if (moderators.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||
moderators.add(user)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeModerator(user: User) {
|
||||
moderators.removeAll { it.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun addRelay(url: NormalizedRelayUrl) {
|
||||
if (relays.none { it.url == url }) {
|
||||
relays.add(CommunityRelayEntry(url))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRelay(entry: CommunityRelayEntry) {
|
||||
relays.removeAll { it.url == entry.url }
|
||||
}
|
||||
|
||||
fun setRelayMarker(
|
||||
entry: CommunityRelayEntry,
|
||||
marker: String?,
|
||||
) {
|
||||
val index = relays.indexOfFirst { it.url == entry.url }
|
||||
if (index >= 0) {
|
||||
relays[index] = entry.copy(marker = marker)
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
!isPublishing &&
|
||||
name.isNotBlank() &&
|
||||
description.isNotBlank()
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun publish(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) = try {
|
||||
publishUnsafe(context, onSuccess, onError)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun publishUnsafe(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
val myAccount = account ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
isPublishing = true
|
||||
try {
|
||||
val uploadedUrl =
|
||||
if (multiOrchestrator != null) {
|
||||
uploadImageIfAny(context, myAccount, onError) ?: return@launch
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val imageUrl = uploadedUrl ?: existingImageUrl
|
||||
|
||||
val ownerKey = myAccount.signer.pubKey
|
||||
val moderatorTags =
|
||||
buildList {
|
||||
add(ModeratorTag(ownerKey, null, "moderator"))
|
||||
moderators
|
||||
.asSequence()
|
||||
.filter { it.pubkeyHex != ownerKey }
|
||||
.forEach { add(ModeratorTag(it.pubkeyHex, null, "moderator")) }
|
||||
}
|
||||
|
||||
val relayTags = relays.map { RelayTag(it.url, it.marker) }
|
||||
|
||||
val dTag = existingDTag ?: Uuid.random().toString()
|
||||
|
||||
val definition =
|
||||
myAccount.sendCommunityDefinition(
|
||||
name = name.trim(),
|
||||
description = description.trim(),
|
||||
moderators = moderatorTags,
|
||||
image = imageUrl,
|
||||
rules = rules.trim().ifBlank { null },
|
||||
relays = relayTags.ifEmpty { null },
|
||||
dTag = dTag,
|
||||
)
|
||||
|
||||
if (definition == null) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Auto-follow only on the create flow; editing doesn't change the follow set.
|
||||
if (existingDTag == null) {
|
||||
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
|
||||
myAccount.follow(communityNote)
|
||||
}
|
||||
|
||||
selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) }
|
||||
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
|
||||
|
||||
reset()
|
||||
onSuccess()
|
||||
} finally {
|
||||
isPublishing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadImageIfAny(
|
||||
context: Context,
|
||||
myAccount: Account,
|
||||
onError: (String, String) -> Unit,
|
||||
): String? {
|
||||
val orch = multiOrchestrator ?: return null
|
||||
val serverToUse = selectedServer ?: defaultServer()
|
||||
|
||||
val results =
|
||||
orch.upload(
|
||||
alt = name.trim().ifBlank { "Community cover" },
|
||||
contentWarningReason = null,
|
||||
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
server = serverToUse,
|
||||
account = myAccount,
|
||||
context = context,
|
||||
useH265 = false,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (!results.allGood) {
|
||||
val messages =
|
||||
results.errors
|
||||
.map { stringRes(context, it.errorResource, *it.params) }
|
||||
.distinct()
|
||||
.joinToString(".\n")
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
|
||||
return null
|
||||
}
|
||||
|
||||
val uploaded =
|
||||
results.successful.firstNotNullOfOrNull {
|
||||
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
} ?: run {
|
||||
onError(
|
||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||
"Upload succeeded but no image URL was returned by the server.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return uploaded.url
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
name = ""
|
||||
description = ""
|
||||
rules = ""
|
||||
existingImageUrl = null
|
||||
existingDTag = null
|
||||
multiOrchestrator = null
|
||||
moderators.clear()
|
||||
relays.clear()
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
}
|
||||
+641
@@ -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) },
|
||||
)
|
||||
}
|
||||
+30
-15
@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByPr
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
|
||||
@@ -91,16 +91,26 @@ fun RenderCommunitiesThumb(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by observeNote(baseNote, accountViewModel)
|
||||
val noteEvent = noteState.note.event as? CommunityDefinitionEvent ?: return
|
||||
// Narrow observation: we only care about the CommunityDefinitionEvent itself,
|
||||
// not every reaction/zap/boost that would otherwise recompose the whole card.
|
||||
val definition by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
|
||||
val event = definition ?: return
|
||||
|
||||
// Memoize card + moderator list identity so LaunchedEffect(moderators) in
|
||||
// LoadModerators doesn't retrigger the ParticipantListBuilder traversal on
|
||||
// every recomposition.
|
||||
val card =
|
||||
remember(event.id) {
|
||||
CommunityCard(
|
||||
name = event.name()?.ifBlank { null } ?: event.dTag(),
|
||||
description = event.description(),
|
||||
cover = event.image()?.imageUrl,
|
||||
moderators = event.moderatorKeys().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
RenderCommunitiesThumb(
|
||||
CommunityCard(
|
||||
name = noteEvent.dTag(),
|
||||
description = noteEvent.description(),
|
||||
cover = noteEvent.image()?.imageUrl,
|
||||
moderators = noteEvent.moderatorKeys().toImmutableList(),
|
||||
),
|
||||
card,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
nav,
|
||||
@@ -193,16 +203,21 @@ fun LoadModerators(
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = moderators) {
|
||||
// Keying by the note id + moderator identity keeps this effect from
|
||||
// restarting on every recomposition (reaction/zap updates would otherwise
|
||||
// reallocate `moderators` and rerun the expensive traversal below).
|
||||
LaunchedEffect(key1 = baseNote.idHex, key2 = moderators) {
|
||||
launch(Dispatchers.IO) {
|
||||
val authorHex = baseNote.author?.pubkeyHex
|
||||
val hosts =
|
||||
moderators.mapNotNull { part ->
|
||||
if (part != baseNote.author?.pubkeyHex) {
|
||||
if (part != authorHex) {
|
||||
LocalCache.checkGetOrCreateUser(part)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val hostSet = hosts.toSet()
|
||||
|
||||
val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value
|
||||
val discoveryTopFilterAuthors =
|
||||
@@ -217,15 +232,15 @@ fun LoadModerators(
|
||||
else -> null
|
||||
}
|
||||
|
||||
val followingKeySet = discoveryTopFilterAuthors
|
||||
val builder = ParticipantListBuilder()
|
||||
val allParticipants =
|
||||
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
|
||||
builder.followsThatParticipateOn(baseNote, discoveryTopFilterAuthors) - hostSet
|
||||
|
||||
val newParticipantUsers =
|
||||
if (followingKeySet == null) {
|
||||
if (discoveryTopFilterAuthors == null) {
|
||||
val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors
|
||||
val followingParticipants =
|
||||
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
|
||||
builder.followsThatParticipateOn(baseNote, allFollows) - hostSet
|
||||
|
||||
(hosts + followingParticipants + (allParticipants - followingParticipants))
|
||||
.toImmutableList()
|
||||
|
||||
+25
-10
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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()
|
||||
}
|
||||
|
||||
+18
-11
@@ -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)
|
||||
}
|
||||
|
||||
+21
@@ -116,6 +116,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
|
||||
ShowThemeChoice(sharedPrefs)
|
||||
ShowImagePreviewChoice(sharedPrefs)
|
||||
ShowVideoPlaybackChoice(sharedPrefs)
|
||||
AutoplayVideosChoice(sharedPrefs)
|
||||
ShowUrlPreviewChoice(sharedPrefs)
|
||||
ShowProfilePictureChoice(sharedPrefs)
|
||||
ImmersiveScrollingChoice(sharedPrefs)
|
||||
@@ -264,6 +265,26 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
|
||||
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
|
||||
|
||||
val booleanItems =
|
||||
persistentListOf(
|
||||
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
|
||||
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
R.string.autoplay_videos,
|
||||
R.string.autoplay_videos_description,
|
||||
booleanItems,
|
||||
autoplayIndex.screenCode,
|
||||
) {
|
||||
sharedPrefs.automaticallyPlayVideos.tryEmit(parseBooleanType(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowUrlPreviewChoice(sharedPrefs: UiSettingsFlow) {
|
||||
val connectivityBasedOptions =
|
||||
|
||||
@@ -386,6 +386,37 @@
|
||||
<string name="polls">Ankiety</string>
|
||||
<string name="open_polls">Otwarte</string>
|
||||
<string name="closed_polls">Zamknięte</string>
|
||||
<string name="badges">Odznaki</string>
|
||||
<string name="received_badges">Otrzymano</string>
|
||||
<string name="my_badges">Moje</string>
|
||||
<string name="awarded_badges">Przyznane</string>
|
||||
<string name="discover_badges">Odkryj</string>
|
||||
<string name="new_badge">Nowa odznaka</string>
|
||||
<string name="edit_badge">Edytuj odznakę</string>
|
||||
<string name="award_badge">Znak wyróżnienia</string>
|
||||
<string name="accept_badge">Zaakceptuj</string>
|
||||
<string name="reject_badge">Odrzuć</string>
|
||||
<string name="unaccept_badge">Usuń z profilu</string>
|
||||
<string name="badge_id_label">ID odznaki (d-tag)</string>
|
||||
<string name="badge_name_label">Nazwa</string>
|
||||
<string name="badge_name_placeholder">Nazwa odznaki czytelna dla człowieka</string>
|
||||
<string name="badge_description_label">Opis</string>
|
||||
<string name="badge_description_placeholder">Za co przyznawana jest ta odznaka?</string>
|
||||
<string name="badge_image_label">Adres URL obrazu (1024×1024)</string>
|
||||
<string name="badge_image_placeholder">https://domena.pl/plakietka.png</string>
|
||||
<string name="badge_thumb_label">URL miniatury (opcjonalnie)</string>
|
||||
<string name="badge_thumb_placeholder">https://domena.pl/miniatura-plakietki.png</string>
|
||||
<string name="badge_upload_image_cta">Wgraj obraz</string>
|
||||
<string name="badge_upload_image_hint">Wybierz kwadratowe zdjęcie, które posłuży jako wizerunek Twojej odznaki.</string>
|
||||
<string name="award_badge_loading">Ładowanie odznaki…</string>
|
||||
<string name="award_badge_search_label">Szukaj użytkowników</string>
|
||||
<string name="award_badge_search_placeholder">Nazwa, npub lub NIP-05</string>
|
||||
<string name="award_badge_remove_recipient">Usuń</string>
|
||||
<string name="badge_awardees_label">Przyznano dla %1$d</string>
|
||||
<string name="badge_award_received_title">Otrzymałeś odznakę</string>
|
||||
<string name="profile_badges_header">Odznaki · %1$d</string>
|
||||
<string name="profile_badges_description">Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.</string>
|
||||
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
|
||||
<string name="pictures">Zdjęcia</string>
|
||||
<string name="shorts">Filmiki</string>
|
||||
<string name="longs">Filmy wideo</string>
|
||||
@@ -400,6 +431,8 @@
|
||||
<string name="pinned_notes_explainer">Twoje przypięte notatki</string>
|
||||
<string name="pin_to_profile">Przypnij do profilu</string>
|
||||
<string name="unpin_from_profile">Odepnij z profilu</string>
|
||||
<string name="deleted_items_banner_remove">Usuń z listy</string>
|
||||
<string name="deleted_items_banner_dismiss">Odrzuć</string>
|
||||
<string name="bookmark_lists">Listy zakładek</string>
|
||||
<string name="bookmark_list_icon_label">Ikona listy zakładek</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nowa lista zakładek</string>
|
||||
@@ -412,6 +445,8 @@
|
||||
<string name="bookmark_list_links_btn_label">Zobacz linki</string>
|
||||
<string name="bookmark_list_hashtags_btn_label">Zobacz Hashtagi</string>
|
||||
<string name="bookmark_list_feed_empty_msg">Nie masz jeszcze żadnych list zakładek. Naciśnij nowy przycisk poniżej, aby utworzyć listę.</string>
|
||||
<string name="interest_set_rename">Zmień nazwę</string>
|
||||
<string name="interest_set_clone">Sklonuj</string>
|
||||
<string name="private_posts_label">Prywatne posty</string>
|
||||
<string name="private_posts_count">Prywatnych postów: (%1$s)</string>
|
||||
<string name="public_posts_label">Publiczne posty</string>
|
||||
@@ -1486,7 +1521,15 @@
|
||||
<string name="feed_group_locations">Lokalizacje</string>
|
||||
<string name="feed_group_communities">Społeczności</string>
|
||||
<string name="feed_group_lists">Listy</string>
|
||||
<string name="feed_group_dvms">Algorytmy kanału</string>
|
||||
<string name="follow_list_all_favorite_dvms">Wszystkie ulubione algorytmy kanału</string>
|
||||
<string name="feed_group_relays">Transmitery</string>
|
||||
<string name="add_dvm_to_favorites">Dodaj algorytm kanału do ulubionych</string>
|
||||
<string name="remove_dvm_from_favorites">Usuń z ulubionych</string>
|
||||
<string name="dvm_home_status_processing">Przetwarzanie statusu kanału…</string>
|
||||
<string name="dvm_home_status_payment_required">Ten algorytm kanału jest płatny</string>
|
||||
<string name="dvm_home_status_error">Algorytm kanału zwrócił błąd</string>
|
||||
<string name="dvm_home_retry">Ponów</string>
|
||||
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
|
||||
<string name="private_message">Wiadomość prywatna</string>
|
||||
<string name="public_message">Publiczna wiadomość</string>
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
因此聊天信息会随着时间的推移而消失。</string>
|
||||
<string name="public_chat">公共聊天</string>
|
||||
<string name="marmot_group">MLS 组</string>
|
||||
<string name="marmot_group_no_messages_yet">尚无消息</string>
|
||||
<string name="public_chat_title">公开聊天元数据</string>
|
||||
<string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以:
|
||||
参与这些聊天。这对特定话题建立的开放式社区来说很重要
|
||||
@@ -390,6 +391,26 @@
|
||||
<string name="open_polls">开启</string>
|
||||
<string name="closed_polls">已关闭</string>
|
||||
<string name="badges">徽章</string>
|
||||
<string name="communities">社区</string>
|
||||
<string name="new_community">新建社区</string>
|
||||
<string name="edit_community">编辑社区</string>
|
||||
<string name="new_community_name">社区名</string>
|
||||
<string name="new_community_description">描述</string>
|
||||
<string name="new_community_rules">规则 (可选)</string>
|
||||
<string name="new_community_pick_cover">添加封面图像</string>
|
||||
<string name="new_community_pick_cover_hint">轻触选取一张照片 — 它将上传到您的媒体服务器。</string>
|
||||
<string name="new_community_moderators_section">协管</string>
|
||||
<string name="new_community_moderators_hint">协管可以批准帖子。您始终是协管。</string>
|
||||
<string name="new_community_add_moderator">添加协管</string>
|
||||
<string name="new_community_add_moderator_placeholder">姓名, npub, 或 NIP-05</string>
|
||||
<string name="new_community_owner">所有者</string>
|
||||
<string name="new_community_relays_section">中继</string>
|
||||
<string name="new_community_relays_hint">选择托管请求、批准或社区作者元数据的中继。</string>
|
||||
<string name="new_community_relay_marker_none">任何</string>
|
||||
<string name="new_community_relay_marker_author">作者</string>
|
||||
<string name="new_community_relay_marker_requests">请求</string>
|
||||
<string name="new_community_relay_marker_approvals">批准</string>
|
||||
<string name="new_community_empty">没有社区匹配此过滤器。</string>
|
||||
<string name="received_badges">已收到</string>
|
||||
<string name="my_badges">我的</string>
|
||||
<string name="awarded_badges">授予的</string>
|
||||
@@ -452,6 +473,20 @@
|
||||
<string name="bookmark_list_links_btn_label">查看链接</string>
|
||||
<string name="bookmark_list_hashtags_btn_label">查看话题标签</string>
|
||||
<string name="bookmark_list_feed_empty_msg">您还没有任何书签列表。轻按下面的新建按钮制作一个。</string>
|
||||
<string name="interest_sets_title">兴趣集</string>
|
||||
<string name="interest_sets_empty">您还没有任何兴趣集。轻按下面的新建按钮制作一个。</string>
|
||||
<string name="interest_set_create_btn_label">新的兴趣集</string>
|
||||
<string name="interest_set_creation_screen_title">新的兴趣集</string>
|
||||
<string name="interest_set_name_label">集合名称</string>
|
||||
<string name="interest_set_name_placeholder">我的兴趣</string>
|
||||
<string name="interest_set_hashtag_add_placeholder">添加话题标签</string>
|
||||
<string name="interest_set_hashtag_private_toggle">私密</string>
|
||||
<string name="interest_set_hashtag_count">%1$d 个话题标签</string>
|
||||
<string name="interest_set_actions_dialog_title">兴趣集操作</string>
|
||||
<string name="interest_set_rename">重命名</string>
|
||||
<string name="interest_set_clone">克隆</string>
|
||||
<string name="interest_set_add_hashtag">添加话题标签</string>
|
||||
<string name="interest_set_toggle_visibility">切换公共/私密</string>
|
||||
<string name="private_posts_label">私密帖子</string>
|
||||
<string name="private_posts_count">私密帖子(%1$s)</string>
|
||||
<string name="public_posts_label">公开帖子</string>
|
||||
@@ -954,6 +989,7 @@
|
||||
<string name="theme">主题</string>
|
||||
<string name="automatically_load_images_gifs">图像预览</string>
|
||||
<string name="automatically_play_videos">视频播放</string>
|
||||
<string name="autoplay_videos">自动播放视频</string>
|
||||
<string name="automatically_show_url_preview">URL 预览</string>
|
||||
<string name="automatically_hide_nav_bars">沉浸式滚动</string>
|
||||
<string name="automatically_hide_nav_bars_description">滚动时隐藏导航栏</string>
|
||||
@@ -1024,6 +1060,7 @@
|
||||
<string name="theme_description">暗色、亮色或系统主题</string>
|
||||
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
|
||||
<string name="automatically_play_videos_description">自动播放视频和 GIF</string>
|
||||
<string name="autoplay_videos_description">视频在屏幕上可见时自动播放视频</string>
|
||||
<string name="automatically_show_url_preview_description">显示 URL 预览</string>
|
||||
<string name="load_image_description">何时加载图像</string>
|
||||
<string name="copy_stack_to_clipboard">复制 Stack</string>
|
||||
@@ -1525,6 +1562,7 @@
|
||||
<string name="select_list_to_filter">选择一个用于过滤订阅源的列表</string>
|
||||
<string name="feed_group_feeds">源</string>
|
||||
<string name="feed_group_hashtags">话题标签</string>
|
||||
<string name="feed_group_interest_sets">兴趣集</string>
|
||||
<string name="feed_group_locations">位置</string>
|
||||
<string name="feed_group_communities">社区</string>
|
||||
<string name="feed_group_lists">列表</string>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
|
||||
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
|
||||
<string name="chat_zap_anonymous">Anonymous</string>
|
||||
<string name="chat_raid_is_raiding">is raiding</string>
|
||||
<string name="chat_clip_created_a_clip">created a clip</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">You are using a public key and public keys are read-only. Login with a Private key to be able to follow</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
|
||||
@@ -284,6 +288,7 @@
|
||||
|
||||
<string name="public_chat">Public Chat</string>
|
||||
<string name="marmot_group">MLS Group</string>
|
||||
<string name="marmot_group_no_messages_yet">No messages yet</string>
|
||||
<string name="public_chat_title">Public Chat Metadata</string>
|
||||
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
|
||||
can participate on them. They are great for open communities around specific topics.
|
||||
@@ -420,6 +425,26 @@
|
||||
<string name="open_polls">Open</string>
|
||||
<string name="closed_polls">Closed</string>
|
||||
<string name="badges">Badges</string>
|
||||
<string name="communities">Communities</string>
|
||||
<string name="new_community">New Community</string>
|
||||
<string name="edit_community">Edit Community</string>
|
||||
<string name="new_community_name">Community name</string>
|
||||
<string name="new_community_description">Description</string>
|
||||
<string name="new_community_rules">Rules (optional)</string>
|
||||
<string name="new_community_pick_cover">Add a cover image</string>
|
||||
<string name="new_community_pick_cover_hint">Tap to pick a photo — it will be uploaded to your media server.</string>
|
||||
<string name="new_community_moderators_section">Moderators</string>
|
||||
<string name="new_community_moderators_hint">Moderators can approve posts. You are always a moderator.</string>
|
||||
<string name="new_community_add_moderator">Add a moderator</string>
|
||||
<string name="new_community_add_moderator_placeholder">Name, npub, or NIP-05</string>
|
||||
<string name="new_community_owner">Owner</string>
|
||||
<string name="new_community_relays_section">Relays</string>
|
||||
<string name="new_community_relays_hint">Pick relays that will host requests, approvals, or the community author\'s metadata.</string>
|
||||
<string name="new_community_relay_marker_none">Any</string>
|
||||
<string name="new_community_relay_marker_author">Author</string>
|
||||
<string name="new_community_relay_marker_requests">Requests</string>
|
||||
<string name="new_community_relay_marker_approvals">Approvals</string>
|
||||
<string name="new_community_empty">No communities match this filter.</string>
|
||||
<string name="received_badges">Received</string>
|
||||
<string name="my_badges">Mine</string>
|
||||
<string name="awarded_badges">Awarded</string>
|
||||
@@ -1092,6 +1117,7 @@
|
||||
<string name="theme">Theme</string>
|
||||
<string name="automatically_load_images_gifs">Image Preview</string>
|
||||
<string name="automatically_play_videos">Video Playback</string>
|
||||
<string name="autoplay_videos">Autoplay Videos</string>
|
||||
<string name="automatically_show_url_preview">URL Preview</string>
|
||||
<string name="automatically_hide_nav_bars">Immersive Scrolling</string>
|
||||
<string name="automatically_hide_nav_bars_description">Hide Nav Bars when Scrolling</string>
|
||||
@@ -1183,7 +1209,8 @@
|
||||
<string name="language_description">For the App\'s Interface</string>
|
||||
<string name="theme_description">Dark, Light or System theme</string>
|
||||
<string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string>
|
||||
<string name="automatically_play_videos_description">Automatically plays videos and GIFs</string>
|
||||
<string name="automatically_play_videos_description">Automatically load videos and GIFs</string>
|
||||
<string name="autoplay_videos_description">Automatically play videos when visible on screen</string>
|
||||
<string name="automatically_show_url_preview_description">Show URL previews</string>
|
||||
<string name="load_image_description">When to load images</string>
|
||||
|
||||
|
||||
+63
@@ -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,
|
||||
)
|
||||
}
|
||||
+36
-4
@@ -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 ---
|
||||
|
||||
+41
@@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,14 @@ class MarmotGroupChatroom(
|
||||
var newestMessage: Note? = null
|
||||
val unreadCount = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Tracks the most recent createdAt (seconds) of a kind:445 group event
|
||||
* observed from each relay, keyed by the relay that delivered it. Used by
|
||||
* the Group Info screen to flag which configured relays are actively
|
||||
* carrying traffic for this MLS group.
|
||||
*/
|
||||
val relayActivity = MutableStateFlow<Map<NormalizedRelayUrl, Long>>(emptyMap())
|
||||
|
||||
/**
|
||||
* True if the local user has ever sent an application message in this
|
||||
* group, OR explicitly created/owns it. Used by list UIs to split groups
|
||||
@@ -59,6 +69,27 @@ class MarmotGroupChatroom(
|
||||
*/
|
||||
var ownerSentMessage: Boolean = false
|
||||
|
||||
// Synthetic note used by list views to represent the group when no
|
||||
// messages have been received yet. Lazily created and kept stable so
|
||||
// equality-based feed diffing treats it as the same row across refreshes.
|
||||
private var cachedPlaceholder: Note? = null
|
||||
|
||||
@Synchronized
|
||||
fun placeholderNote(): Note {
|
||||
val existing = cachedPlaceholder
|
||||
if (existing != null) return existing
|
||||
val created =
|
||||
Note(placeholderIdHex(nostrGroupId)).apply {
|
||||
addGatherer(this@MarmotGroupChatroom)
|
||||
}
|
||||
cachedPlaceholder = created
|
||||
return created
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
|
||||
}
|
||||
|
||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
@@ -134,6 +165,16 @@ class MarmotGroupChatroom(
|
||||
unreadCount.value = 0
|
||||
}
|
||||
|
||||
fun recordRelayActivity(
|
||||
relay: NormalizedRelayUrl,
|
||||
createdAt: Long,
|
||||
) {
|
||||
relayActivity.update { current ->
|
||||
val existing = current[relay] ?: 0L
|
||||
if (createdAt > existing) current + (relay to createdAt) else current
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = messages.sortedWith(DefaultFeedOrder)
|
||||
val toKeep =
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.nip53LiveActivities
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A single entry on a stream's top-zappers leaderboard.
|
||||
*
|
||||
* @param bucketKey either a real zapper pubkey or [LiveActivityTopZappersAggregator.ANON_KEY]
|
||||
* for zaps whose zap-request carried an `anon` tag.
|
||||
* @param totalSats sum of amounts across every zap that mapped to this bucket.
|
||||
* @param isAnonymous true when the bucket is the shared anonymous pool.
|
||||
*/
|
||||
@Immutable
|
||||
data class TopZapperEntry(
|
||||
val bucketKey: HexKey,
|
||||
val totalSats: Long,
|
||||
val isAnonymous: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single zap-receipt contribution before aggregation. All inputs are
|
||||
* plain values so this stays testable in pure Kotlin without Compose or Android.
|
||||
*
|
||||
* @param receiptId the kind-9735 receipt event id — used to de-duplicate receipts that
|
||||
* appear in both the stream's #a and the goal's #e feed.
|
||||
* @param zapperPubKey pubkey of the zap-request signer (random one-time key for anon/private).
|
||||
* @param isAnonymous true when the zap-request carried an `anon` tag (empty or encrypted).
|
||||
* @param sats amount in satoshis.
|
||||
*/
|
||||
@Immutable
|
||||
data class ZapContribution(
|
||||
val receiptId: HexKey,
|
||||
val zapperPubKey: HexKey,
|
||||
val isAnonymous: Boolean,
|
||||
val sats: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Computes a top-N zap leaderboard for a live stream.
|
||||
*
|
||||
* Inputs are raw [ZapContribution]s from any number of sources; the aggregator handles:
|
||||
* - de-duplication by receipt id (a zap that tags both `#a` and `#e` counts once),
|
||||
* - anonymous bucketing (all `anon`-tagged zaps collapse into one entry),
|
||||
* - sum-by-contributor and sort-desc-by-total,
|
||||
* - top-N truncation.
|
||||
*
|
||||
* This is a pure function — no Compose, no Android, no LocalCache — so it can be unit
|
||||
* tested in isolation and reused from any platform target.
|
||||
*/
|
||||
object LiveActivityTopZappersAggregator {
|
||||
/** Sentinel key that collapses every anonymous / private zap into a single leaderboard entry. */
|
||||
const val ANON_KEY: HexKey = "anon"
|
||||
|
||||
/** Default cap — matches zap.stream's TopZappers limit. */
|
||||
const val DEFAULT_LIMIT = 10
|
||||
|
||||
fun aggregate(
|
||||
contributions: Iterable<ZapContribution>,
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
): List<TopZapperEntry> {
|
||||
if (limit <= 0) return emptyList()
|
||||
|
||||
// receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e.
|
||||
val unique = HashMap<HexKey, ZapContribution>()
|
||||
for (c in contributions) {
|
||||
unique[c.receiptId] = c
|
||||
}
|
||||
if (unique.isEmpty()) return emptyList()
|
||||
|
||||
val totals = HashMap<HexKey, Long>(unique.size)
|
||||
var hasAnon = false
|
||||
for (c in unique.values) {
|
||||
val key = if (c.isAnonymous) ANON_KEY else c.zapperPubKey
|
||||
if (c.isAnonymous) hasAnon = true
|
||||
totals[key] = (totals[key] ?: 0L) + c.sats
|
||||
}
|
||||
|
||||
return totals.entries
|
||||
.asSequence()
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY && hasAnon) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.ListChange
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Live-stream top-zappers leaderboard state, computed off the UI thread and maintained
|
||||
* incrementally.
|
||||
*
|
||||
* The leaderboard draws from two independent contribution sets keyed by zap-receipt id:
|
||||
* - [streamContributions] — zaps routed into the [LiveActivitiesChannel]'s cache via the
|
||||
* stream's `#a` subscription;
|
||||
* - [goalContributions] — zaps attached to a NIP-75 goal note via `#e`.
|
||||
*
|
||||
* The union is flattened on each change and fed into [LiveActivityTopZappersAggregator] for
|
||||
* de-dup, anonymous bucketing, sum-by-contributor, and top-N sort, publishing a stable
|
||||
* `List<TopZapperEntry>` via [topZappers] for dumb UI consumption.
|
||||
*/
|
||||
@Stable
|
||||
class LiveStreamTopZappersViewModel(
|
||||
private val channel: LiveActivitiesChannel,
|
||||
private val limit: Int = LiveActivityTopZappersAggregator.DEFAULT_LIMIT,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// receiptId -> ZapContribution, partitioned by source. Aggregator dedupes at merge.
|
||||
private val streamContributions = HashMap<HexKey, ZapContribution>()
|
||||
private val goalContributions = HashMap<HexKey, ZapContribution>()
|
||||
|
||||
private val _topZappers = MutableStateFlow<List<TopZapperEntry>>(emptyList())
|
||||
val topZappers: StateFlow<List<TopZapperEntry>> = _topZappers.asStateFlow()
|
||||
|
||||
private var goalNote: Note? = null
|
||||
private var goalObserverJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Seed from the channel cache.
|
||||
mutex.withLock {
|
||||
channel.notes.forEach { _, note ->
|
||||
contributionFromStreamZap(note)?.let { streamContributions[it.receiptId] = it }
|
||||
}
|
||||
}
|
||||
publish()
|
||||
|
||||
// Keep in sync with subsequent arrivals.
|
||||
channel.changesFlow().collect { change ->
|
||||
val mutated = applyChannelChange(change)
|
||||
if (mutated) publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or detach) a NIP-75 goal note whose zaps should flow into the leaderboard.
|
||||
* Passing the same instance twice is a no-op; passing null clears the goal source.
|
||||
*/
|
||||
fun setGoalNote(newGoalNote: Note?) {
|
||||
if (newGoalNote === goalNote) return
|
||||
goalNote = newGoalNote
|
||||
|
||||
goalObserverJob?.cancel()
|
||||
goalObserverJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Reset goal bucket and re-seed from the new note (if any) before subscribing.
|
||||
rebuildGoalContributions(newGoalNote)
|
||||
publish()
|
||||
|
||||
if (newGoalNote == null) return@launch
|
||||
|
||||
// Observe future goal-zap arrivals. Each emission rebuilds the goal bucket;
|
||||
// the aggregator already handles de-dup at merge-time.
|
||||
newGoalNote.flow().zaps.stateFlow.collect {
|
||||
rebuildGoalContributions(newGoalNote)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyChannelChange(change: ListChange<Note>): Boolean =
|
||||
mutex.withLock {
|
||||
when (change) {
|
||||
is ListChange.Addition -> upsertStream(change.item)
|
||||
is ListChange.Deletion -> removeStream(change.item.idHex)
|
||||
is ListChange.SetAddition -> change.item.fold(false) { acc, n -> upsertStream(n) || acc }
|
||||
is ListChange.SetDeletion -> change.item.fold(false) { acc, n -> removeStream(n.idHex) || acc }
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertStream(note: Note): Boolean {
|
||||
val c = contributionFromStreamZap(note) ?: return false
|
||||
val prev = streamContributions[c.receiptId]
|
||||
if (prev == c) return false
|
||||
streamContributions[c.receiptId] = c
|
||||
return true
|
||||
}
|
||||
|
||||
private fun removeStream(receiptId: HexKey): Boolean = streamContributions.remove(receiptId) != null
|
||||
|
||||
private suspend fun rebuildGoalContributions(goal: Note?) {
|
||||
mutex.withLock {
|
||||
goalContributions.clear()
|
||||
goal?.zaps?.forEach { (zapRequestNote, receiptNote) ->
|
||||
contributionFromGoalZap(zapRequestNote, receiptNote)?.let {
|
||||
goalContributions[it.receiptId] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val merged = streamContributions.values + goalContributions.values
|
||||
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
|
||||
}
|
||||
|
||||
private fun contributionFromStreamZap(note: Note): ZapContribution? {
|
||||
val ev = note.event as? LnZapEvent ?: return null
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
|
||||
private fun contributionFromGoalZap(
|
||||
zapRequestNote: Note,
|
||||
receiptNote: Note?,
|
||||
): ZapContribution? {
|
||||
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
|
||||
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
|
||||
val sats = receiptEv.amount()?.toLong() ?: return null
|
||||
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
}
|
||||
|
||||
/** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */
|
||||
private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" }
|
||||
+153
@@ -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())
|
||||
}
|
||||
}
|
||||
+141
-26
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -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 =
|
||||
|
||||
+91
-5
@@ -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.
|
||||
|
||||
+63
-45
@@ -100,6 +100,9 @@ data class MarmotGroupData(
|
||||
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
|
||||
"disappearing_message_secs must be > 0 when set (MIP-01)"
|
||||
}
|
||||
require(adminPubkeys.size == adminPubkeys.toSet().size) {
|
||||
"MarmotGroupData.admin_pubkeys MUST NOT contain duplicates (MIP-01)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the given pubkey is an admin of this group */
|
||||
@@ -111,46 +114,58 @@ data class MarmotGroupData(
|
||||
/**
|
||||
* Encode this MarmotGroupData to TLS wire format bytes.
|
||||
* Mirrors the [decodeTls] format.
|
||||
*
|
||||
* Per MIP-01, all variable-length vectors use QUIC-style variable-length integer
|
||||
* (VarInt) length prefixes, as implemented by the Rust `tls_codec` crate (v0.4+):
|
||||
* - lengths 0..63 → 1 byte (high bits 00)
|
||||
* - lengths 64..16383 → 2 bytes (high bits 01)
|
||||
* - lengths 16384+ → 4 bytes (high bits 10)
|
||||
*/
|
||||
fun encodeTls(): ByteArray {
|
||||
val writer = TlsWriter()
|
||||
writer.putUint16(version)
|
||||
writer.putBytes(nostrGroupId.hexToByteArray())
|
||||
writer.putOpaque2(name.encodeToByteArray())
|
||||
writer.putOpaque2(description.encodeToByteArray())
|
||||
writer.putOpaqueVarInt(name.encodeToByteArray())
|
||||
writer.putOpaqueVarInt(description.encodeToByteArray())
|
||||
|
||||
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
|
||||
// admin_pubkeys: Vec<[u8;32]> — outer VarInt covers total bytes, each 32-byte
|
||||
// key is fixed-size with no inner length prefix.
|
||||
val adminBytes = ByteArray(adminPubkeys.size * 32)
|
||||
adminPubkeys.forEachIndexed { index, key ->
|
||||
key.hexToByteArray().copyInto(adminBytes, index * 32)
|
||||
}
|
||||
writer.putOpaque2(adminBytes)
|
||||
writer.putOpaqueVarInt(adminBytes)
|
||||
|
||||
// Relays: length-prefixed block of length-prefixed UTF-8 strings
|
||||
// relays: Vec<Vec<u8>> — outer VarInt covers total bytes, each inner relay
|
||||
// string is VarInt-length-prefixed UTF-8.
|
||||
val relayWriter = TlsWriter()
|
||||
for (relay in relays) {
|
||||
relayWriter.putOpaque2(relay.encodeToByteArray())
|
||||
relayWriter.putOpaqueVarInt(relay.encodeToByteArray())
|
||||
}
|
||||
writer.putOpaque2(relayWriter.toByteArray())
|
||||
writer.putOpaqueVarInt(relayWriter.toByteArray())
|
||||
|
||||
// Optional image fields
|
||||
writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0))
|
||||
writer.putOpaque2(imageKey ?: ByteArray(0))
|
||||
writer.putOpaque2(imageNonce ?: ByteArray(0))
|
||||
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
|
||||
// Optional image fields — empty Vec<u8> encodes as a single zero byte (VarInt(0)).
|
||||
writer.putOpaqueVarInt(imageHash?.hexToByteArray() ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageKey ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
|
||||
|
||||
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs)
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
val out = ByteArray(8)
|
||||
var v = secs.toLong()
|
||||
for (i in 7 downTo 0) {
|
||||
out[i] = (v and 0xFF).toByte()
|
||||
v = v ushr 8
|
||||
}
|
||||
out
|
||||
} ?: ByteArray(0)
|
||||
writer.putOpaque2(disappearingBytes)
|
||||
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
|
||||
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
|
||||
// the wire format byte-for-byte compatible with older implementations (MDK v2).
|
||||
if (version >= 3) {
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
val out = ByteArray(8)
|
||||
var v = secs.toLong()
|
||||
for (i in 7 downTo 0) {
|
||||
out[i] = (v and 0xFF).toByte()
|
||||
v = v ushr 8
|
||||
}
|
||||
out
|
||||
} ?: ByteArray(0)
|
||||
writer.putOpaqueVarInt(disappearingBytes)
|
||||
}
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
@@ -182,19 +197,22 @@ data class MarmotGroupData(
|
||||
/**
|
||||
* Decode MarmotGroupData from TLS wire format bytes.
|
||||
*
|
||||
* Per MIP-01, all variable-length vectors use QUIC-style VarInt length prefixes
|
||||
* (`tls_codec` v0.4+). The TLS comment syntax below uses `<V>` to denote VarInt.
|
||||
*
|
||||
* Wire format (v3):
|
||||
* ```
|
||||
* uint16 version // rejected if 0 or unsupported
|
||||
* opaque nostr_group_id[32]
|
||||
* opaque name<0..2^16-1>
|
||||
* opaque description<0..2^16-1>
|
||||
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
|
||||
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
|
||||
* opaque image_hash<0..32>
|
||||
* opaque image_key<0..32>
|
||||
* opaque image_nonce<0..12>
|
||||
* opaque image_upload_key<0..32>
|
||||
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||
* opaque name<V>
|
||||
* opaque description<V>
|
||||
* opaque admin_pubkeys<V> // concatenated 32-byte keys
|
||||
* RelayUrl relays<V> // VarInt-length-prefixed UTF-8 strings
|
||||
* opaque image_hash<V>
|
||||
* opaque image_key<V>
|
||||
* opaque image_nonce<V>
|
||||
* opaque image_upload_key<V>
|
||||
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||
* ```
|
||||
*
|
||||
* Unknown trailing bytes from future versions are silently ignored for
|
||||
@@ -209,14 +227,14 @@ data class MarmotGroupData(
|
||||
val nostrGroupIdBytes = reader.readBytes(32)
|
||||
val nostrGroupId = nostrGroupIdBytes.toHexKey()
|
||||
|
||||
val nameBytes = reader.readOpaque2()
|
||||
val nameBytes = reader.readOpaqueVarInt()
|
||||
val name = nameBytes.decodeToString()
|
||||
|
||||
val descriptionBytes = reader.readOpaque2()
|
||||
val descriptionBytes = reader.readOpaqueVarInt()
|
||||
val description = descriptionBytes.decodeToString()
|
||||
|
||||
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
|
||||
val adminBlock = reader.readOpaque2()
|
||||
// Admin pubkeys: concatenated 32-byte keys within a VarInt-prefixed block
|
||||
val adminBlock = reader.readOpaqueVarInt()
|
||||
val adminPubkeys = mutableListOf<HexKey>()
|
||||
var i = 0
|
||||
while (i + 32 <= adminBlock.size) {
|
||||
@@ -224,23 +242,23 @@ data class MarmotGroupData(
|
||||
i += 32
|
||||
}
|
||||
|
||||
// Relays: length-prefixed block of length-prefixed UTF-8 strings
|
||||
val relaysBlock = reader.readOpaque2()
|
||||
// Relays: VarInt-prefixed block of VarInt-prefixed UTF-8 strings
|
||||
val relaysBlock = reader.readOpaqueVarInt()
|
||||
val relays = mutableListOf<String>()
|
||||
val relayReader = TlsReader(relaysBlock)
|
||||
while (relayReader.hasRemaining) {
|
||||
val relayBytes = relayReader.readOpaque2()
|
||||
val relayBytes = relayReader.readOpaqueVarInt()
|
||||
relays.add(relayBytes.decodeToString())
|
||||
}
|
||||
|
||||
// Optional fields — read if remaining
|
||||
val imageHash = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() }?.toHexKey() else null
|
||||
val imageKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageHash = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.toHexKey() else null
|
||||
val imageKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
val imageNonce = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
val imageUploadKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
|
||||
// v3+: disappearing_message_secs
|
||||
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
|
||||
val disappearingBytes = if (reader.hasRemaining) reader.readOpaqueVarInt() else ByteArray(0)
|
||||
val disappearingMessageSecs: ULong? =
|
||||
when (disappearingBytes.size) {
|
||||
0 -> {
|
||||
|
||||
+11
-4
@@ -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,
|
||||
|
||||
+18
-19
@@ -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)
|
||||
|
||||
+5
-9
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -33,8 +32,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
* Unsigned application message sent inside a GroupEvent (kind:445) when a device
|
||||
* leaves a group or wants to disable push notifications.
|
||||
*
|
||||
* Has no tags — the MLS leaf index is implicit from the MLS sender identity.
|
||||
* Receiving clients MUST remove the token for the identified leaf.
|
||||
* Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit
|
||||
* from the MLS sender identity; receiving clients MUST remove the token for
|
||||
* the identified leaf. Adding extra tags could leak metadata or be rejected
|
||||
* by strict MIP-05 validators (e.g. the MDK reference).
|
||||
*
|
||||
* MUST remain unsigned (no sig field) per MIP-03 security requirements.
|
||||
*/
|
||||
@@ -50,11 +51,6 @@ class TokenRemovalEvent(
|
||||
companion object {
|
||||
const val KIND = 449
|
||||
|
||||
fun build(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<TokenRemovalEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
initializer()
|
||||
}
|
||||
fun build(createdAt: Long = TimeUtils.now()) = eventTemplate<TokenRemovalEvent>(KIND, "", createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.ensure
|
||||
*/
|
||||
@Immutable
|
||||
data class TokenTagData(
|
||||
/** Base64-encoded EncryptedToken (280 bytes when decoded) */
|
||||
/** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */
|
||||
val encryptedToken: String,
|
||||
/** Hex-encoded notification server public key */
|
||||
val serverPubKey: HexKey,
|
||||
@@ -52,8 +52,11 @@ class TokenTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "token"
|
||||
|
||||
/** Expected decoded size of an EncryptedToken */
|
||||
const val ENCRYPTED_TOKEN_SIZE = 280
|
||||
/**
|
||||
* Expected decoded size of an EncryptedToken per MIP-05:
|
||||
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes.
|
||||
*/
|
||||
const val ENCRYPTED_TOKEN_SIZE = 1084
|
||||
|
||||
fun parse(tag: Array<String>): TokenTagData? {
|
||||
ensure(tag.has(3) && tag[0] == TAG_NAME) { return null }
|
||||
|
||||
+37
-4
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.marmot.mls.framing
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
|
||||
|
||||
/**
|
||||
* MLS MLSMessage (RFC 9420 Section 6).
|
||||
@@ -161,7 +163,17 @@ data class PublicMessage(
|
||||
encodeSender(writer, sender)
|
||||
writer.putOpaqueVarInt(authenticatedData)
|
||||
writer.putUint8(contentType.value)
|
||||
writer.putBytes(content)
|
||||
|
||||
// RFC 9420 §6 FramedContent.content is a type-dependent body:
|
||||
// case application: opaque application_data<V>
|
||||
// case proposal: Proposal proposal (struct, no outer length prefix)
|
||||
// case commit: Commit commit (struct, no outer length prefix)
|
||||
// `content` holds the already-serialized body for proposal/commit, and
|
||||
// the raw application bytes for application.
|
||||
when (contentType) {
|
||||
ContentType.APPLICATION -> writer.putOpaqueVarInt(content)
|
||||
ContentType.PROPOSAL, ContentType.COMMIT -> writer.putBytes(content)
|
||||
}
|
||||
|
||||
// FramedContentAuthData
|
||||
writer.putOpaqueVarInt(signature)
|
||||
@@ -191,9 +203,30 @@ data class PublicMessage(
|
||||
val authenticatedData = reader.readOpaqueVarInt()
|
||||
val contentType = ContentType.fromValue(reader.readUint8())
|
||||
|
||||
// Content is variable based on content_type, read remaining content
|
||||
// For now, read as opaque
|
||||
val content = reader.readOpaqueVarInt()
|
||||
// RFC 9420 §6 FramedContent.content body varies by content_type.
|
||||
// For PROPOSAL/COMMIT we decode the inner struct to advance the reader
|
||||
// and then re-serialize back to bytes so the invariant
|
||||
// "content holds the serialized body" holds for all variants.
|
||||
val content: ByteArray =
|
||||
when (contentType) {
|
||||
ContentType.APPLICATION -> {
|
||||
reader.readOpaqueVarInt()
|
||||
}
|
||||
|
||||
ContentType.PROPOSAL -> {
|
||||
val proposal = Proposal.decodeTls(reader)
|
||||
val w = TlsWriter()
|
||||
proposal.encodeTls(w)
|
||||
w.toByteArray()
|
||||
}
|
||||
|
||||
ContentType.COMMIT -> {
|
||||
val commit = Commit.decodeTls(reader)
|
||||
val w = TlsWriter()
|
||||
commit.encodeTls(w)
|
||||
w.toByteArray()
|
||||
}
|
||||
}
|
||||
val signature = reader.readOpaqueVarInt()
|
||||
|
||||
val confirmationTag =
|
||||
|
||||
@@ -31,6 +31,9 @@ import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.ContentType
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.Sender
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.SenderType
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
|
||||
@@ -400,6 +403,13 @@ class MlsGroup private constructor(
|
||||
// (i.e. no remaining member appears in the post-commit admin list).
|
||||
enforceNoAdminDepletion(proposals)
|
||||
|
||||
// Capture the pre-commit exporter secret BEFORE any mutation.
|
||||
// Publishers of the outbound kind:445 MUST outer-encrypt with this
|
||||
// key (epoch N) so that other existing members at epoch N can decrypt
|
||||
// and process the commit. See CommitResult.preCommitExporterSecret.
|
||||
val preCommitExporterSecret =
|
||||
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
|
||||
|
||||
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
||||
|
||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||
@@ -502,7 +512,10 @@ class MlsGroup private constructor(
|
||||
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
|
||||
|
||||
val newTreeHash = tree.treeHash()
|
||||
val newEpoch = groupContext.epoch + 1
|
||||
val oldEpoch = groupContext.epoch
|
||||
val preCommitGroupId = groupContext.groupId
|
||||
val committerLeafIndex = myLeafIndex
|
||||
val newEpoch = oldEpoch + 1
|
||||
|
||||
groupContext =
|
||||
groupContext.copy(
|
||||
@@ -544,7 +557,21 @@ class MlsGroup private constructor(
|
||||
sentKeys.clear()
|
||||
|
||||
val commitBytes = commit.toTlsBytes()
|
||||
return CommitResult(commitBytes, welcomeBytes, null)
|
||||
val framedCommitBytes =
|
||||
framePublicMessageCommit(
|
||||
groupId = preCommitGroupId,
|
||||
epoch = oldEpoch,
|
||||
senderLeafIndex = committerLeafIndex,
|
||||
commitBytes = commitBytes,
|
||||
confirmationTag = confirmationTag,
|
||||
)
|
||||
return CommitResult(
|
||||
commitBytes = commitBytes,
|
||||
welcomeBytes = welcomeBytes,
|
||||
groupInfoBytes = null,
|
||||
framedCommitBytes = framedCommitBytes,
|
||||
preCommitExporterSecret = preCommitExporterSecret,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Message Encryption ---
|
||||
@@ -1475,6 +1502,38 @@ class MlsGroup private constructor(
|
||||
private const val REUSE_GUARD_LENGTH = 4
|
||||
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
|
||||
|
||||
/**
|
||||
* Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...))
|
||||
* envelope so it can be published on the wire (RFC 9420 §6 / §6.2).
|
||||
*
|
||||
* The receiver uses the sender's leaf index and the confirmation_tag from
|
||||
* the [PublicMessage] header to drive [MlsGroup.processCommit]. The
|
||||
* `signature` and `membership_tag` opaque fields are intentionally empty —
|
||||
* the current implementation does not verify them on inbound commits,
|
||||
* but the TLS structure must still be present so decoding succeeds.
|
||||
*/
|
||||
internal fun framePublicMessageCommit(
|
||||
groupId: ByteArray,
|
||||
epoch: Long,
|
||||
senderLeafIndex: Int,
|
||||
commitBytes: ByteArray,
|
||||
confirmationTag: ByteArray,
|
||||
): ByteArray {
|
||||
val publicMessage =
|
||||
PublicMessage(
|
||||
groupId = groupId,
|
||||
epoch = epoch,
|
||||
sender = Sender(SenderType.MEMBER, senderLeafIndex),
|
||||
authenticatedData = ByteArray(0),
|
||||
contentType = ContentType.COMMIT,
|
||||
content = commitBytes,
|
||||
signature = ByteArray(0),
|
||||
confirmationTag = confirmationTag,
|
||||
membershipTag = ByteArray(0),
|
||||
)
|
||||
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ConfirmedTranscriptHashInput (RFC 9420 Section 8.2) — static version
|
||||
* usable from both instance methods and companion object factory methods.
|
||||
@@ -2020,7 +2079,9 @@ class MlsGroup private constructor(
|
||||
|
||||
/**
|
||||
* Add a member to the group by their KeyPackage.
|
||||
* Creates and applies a Commit with an Add proposal.
|
||||
* Creates and applies a Commit with an Add proposal. The resulting
|
||||
* [CommitResult.preCommitExporterSecret] is the key the outer kind:445
|
||||
* MUST be encrypted with (RFC 9420 §12.4 + MDK parity).
|
||||
*/
|
||||
fun addMember(keyPackageBytes: ByteArray): CommitResult {
|
||||
proposeAdd(keyPackageBytes)
|
||||
@@ -2029,7 +2090,8 @@ class MlsGroup private constructor(
|
||||
|
||||
/**
|
||||
* Remove a member from the group.
|
||||
* Creates and applies a Commit with a Remove proposal.
|
||||
* Creates and applies a Commit with a Remove proposal. See [addMember]
|
||||
* for the pre-commit exporter key contract on the returned [CommitResult].
|
||||
*/
|
||||
fun removeMember(targetLeafIndex: Int): CommitResult {
|
||||
proposeRemove(targetLeafIndex)
|
||||
|
||||
+46
-30
@@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock
|
||||
* ## Cross-Implementation Notes
|
||||
*
|
||||
* This manager uses ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).
|
||||
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets
|
||||
* for the current and previous epoch are kept for late-message decryption.
|
||||
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 5, matching the
|
||||
* `DEFAULT_EPOCH_LOOKBACK` used by the MDK reference implementation so that late-
|
||||
* arriving MIP-03 GroupEvents (and MLS application messages) whose outer ChaCha20-
|
||||
* Poly1305 key was derived from a prior epoch's exporter secret can still be
|
||||
* decrypted after a Commit advances the group.
|
||||
*
|
||||
* Thread safety: All suspending mutation methods are guarded by a [Mutex]
|
||||
* to prevent concurrent state corruption. Non-suspending read methods
|
||||
@@ -264,11 +267,9 @@ class MlsGroupManager(
|
||||
suspend fun commit(nostrGroupId: HexKey): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
|
||||
// Retain current epoch secrets before transition
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
@@ -289,10 +290,16 @@ class MlsGroupManager(
|
||||
) = mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
|
||||
// Retain current epoch secrets before transition
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
// Capture the outgoing epoch's secrets BEFORE advancing, but only
|
||||
// commit them to the retention window once processCommit succeeds —
|
||||
// otherwise a failed commit (e.g. "Duplicate encryption key" on an
|
||||
// add-me relay echo) would pollute the window with a duplicate of
|
||||
// the current epoch key, wasting the finite retention slots.
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
|
||||
group.processCommit(commitBytes, senderLeafIndex, confirmationTag)
|
||||
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
@@ -356,6 +363,12 @@ class MlsGroupManager(
|
||||
|
||||
/**
|
||||
* Add a member and create a Commit.
|
||||
*
|
||||
* The returned [CommitResult.preCommitExporterSecret] is the key the
|
||||
* outbound kind:445 MUST be outer-encrypted with (RFC 9420 §12.4 + MDK
|
||||
* parity). The local group state advances to the new epoch before this
|
||||
* function returns; publishers get one shot at the correct pre-commit
|
||||
* key via the [CommitResult] payload.
|
||||
*/
|
||||
suspend fun addMember(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -363,14 +376,16 @@ class MlsGroupManager(
|
||||
): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.addMember(keyPackageBytes)
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member and create a Commit.
|
||||
* Remove a member and create a Commit. See [addMember] for the
|
||||
* pre-commit exporter key contract on the returned [CommitResult].
|
||||
*/
|
||||
suspend fun removeMember(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -378,8 +393,9 @@ class MlsGroupManager(
|
||||
): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.removeMember(targetLeafIndex)
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
@@ -388,31 +404,22 @@ class MlsGroupManager(
|
||||
* Rotate the signing key within a group and commit.
|
||||
*
|
||||
* Per MIP-00, members SHOULD self-update within 24 hours of joining.
|
||||
* This creates an Update proposal with a fresh signing key and commits it.
|
||||
*
|
||||
* @param nostrGroupId hex-encoded Nostr group ID
|
||||
* @return the [CommitResult] to publish
|
||||
* See [addMember] for the pre-commit exporter key contract.
|
||||
*/
|
||||
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
group.proposeSigningKeyRotation()
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
||||
* Creates the proposal, commits it, and persists the new state.
|
||||
*
|
||||
* Authorization (MIP-01): extension updates require admin privileges once
|
||||
* the group has at least one admin. During bootstrap — before any
|
||||
* `admin_pubkeys` are configured — any member may seed the initial
|
||||
* extension set (e.g. the creator installing the first
|
||||
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with
|
||||
* themselves as admin).
|
||||
* See [addMember] for the pre-commit exporter key contract.
|
||||
*/
|
||||
suspend fun updateGroupExtensions(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -425,9 +432,10 @@ class MlsGroupManager(
|
||||
check(!adminsConfigured || group.isLocalAdmin()) {
|
||||
"MIP-01: only admins may update group extensions"
|
||||
}
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
group.proposeGroupContextExtensions(extensions)
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
@@ -561,12 +569,18 @@ class MlsGroupManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun retainEpochSecrets(
|
||||
/**
|
||||
* Push a previously-captured [RetainedEpochSecrets] into the bounded
|
||||
* retention window. Call after the epoch advance has been applied
|
||||
* successfully so that failed commits don't pollute the window with
|
||||
* duplicate current-epoch keys.
|
||||
*/
|
||||
private fun pushRetainedEpoch(
|
||||
nostrGroupId: HexKey,
|
||||
group: MlsGroup,
|
||||
retainedBefore: RetainedEpochSecrets,
|
||||
) {
|
||||
val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() }
|
||||
retained.add(group.retainedSecrets())
|
||||
retained.add(retainedBefore)
|
||||
|
||||
// Trim to retention window (keep only the most recent N-1 epochs)
|
||||
while (retained.size > EPOCH_RETENTION_WINDOW) {
|
||||
@@ -662,9 +676,11 @@ class MlsGroupManager(
|
||||
|
||||
/**
|
||||
* Number of past epochs to retain for late-arriving message decryption.
|
||||
* MLS forward secrecy guarantees mean we want to limit this window.
|
||||
* Matches MDK's `DEFAULT_EPOCH_LOOKBACK` so a message encrypted under
|
||||
* the prior N epochs' exporter secrets can still be decrypted after a
|
||||
* Commit advances the group. Capped for forward-secrecy reasons.
|
||||
*/
|
||||
const val EPOCH_RETENTION_WINDOW = 2
|
||||
const val EPOCH_RETENTION_WINDOW = 5
|
||||
|
||||
/** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */
|
||||
private const val REUSE_GUARD_LENGTH = 4
|
||||
|
||||
@@ -91,11 +91,34 @@ data class UpdatePath(
|
||||
|
||||
/**
|
||||
* Result of creating a Commit: the MLS messages to distribute.
|
||||
*
|
||||
* [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful
|
||||
* for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit]
|
||||
* entry point. For on-the-wire distribution, callers MUST publish
|
||||
* [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope)
|
||||
* so that receivers can parse the sender's leaf index and confirmation tag.
|
||||
*/
|
||||
data class CommitResult(
|
||||
val commitBytes: ByteArray,
|
||||
val welcomeBytes: ByteArray?,
|
||||
val groupInfoBytes: ByteArray?,
|
||||
/**
|
||||
* Fully-framed commit ready for the MIP-03 outer ChaCha20 encryption.
|
||||
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
|
||||
*/
|
||||
val framedCommitBytes: ByteArray = commitBytes,
|
||||
/**
|
||||
* `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit**
|
||||
* epoch (N) — the key the group had when this commit was computed, before
|
||||
* local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap
|
||||
* the commit with this key (RFC 9420 §12.4 and MDK parity). Using the
|
||||
* post-commit (N+1) key makes the commit unreadable to existing members
|
||||
* still at epoch N — the exact scenario that caused Eden to stall at
|
||||
* epoch 1 and never decrypt any of David's subsequent messages.
|
||||
*
|
||||
* Empty by default for the test-only entry points that don't need it.
|
||||
*/
|
||||
val preCommitExporterSecret: ByteArray = ByteArray(0),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
+41
-34
@@ -47,9 +47,6 @@ class SecretTree(
|
||||
private val encryptionSecret: ByteArray,
|
||||
private val leafCount: Int,
|
||||
) {
|
||||
/** Cached tree node secrets, computed lazily */
|
||||
private val treeSecrets = mutableMapOf<Int, ByteArray>()
|
||||
|
||||
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
|
||||
private val senderState = mutableMapOf<Int, SenderRatchetState>()
|
||||
|
||||
@@ -72,11 +69,6 @@ class SecretTree(
|
||||
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
|
||||
}
|
||||
|
||||
init {
|
||||
// Seed the root
|
||||
treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next (key, nonce) for application messages from a given sender.
|
||||
*/
|
||||
@@ -252,36 +244,51 @@ class SecretTree(
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the leaf secret from the encryption secret using the tree structure.
|
||||
* Derive the leaf secret from the encryption secret by walking DOWN the
|
||||
* binary tree from the root to the target leaf (RFC 9420 §9).
|
||||
*
|
||||
* At each step we pick left or right based on which subtree contains the
|
||||
* target. In an MLS left-balanced tree the left-subtree node indices are
|
||||
* always strictly less than the current node, and the right-subtree
|
||||
* indices strictly greater — so the direction is simply
|
||||
* `targetNode < currentNode`.
|
||||
*
|
||||
* This also correctly handles non-power-of-2 leaf counts (3, 5, 6, 7, 9
|
||||
* …) where the rightmost leaves live beneath "virtual" intermediate
|
||||
* nodes that are beyond `nodeCount`. Walking down still succeeds because
|
||||
* [BinaryTree.left] / [BinaryTree.right] give the correct descendants
|
||||
* even for virtual nodes, and the target leaf is reachable via a chain
|
||||
* of left/right steps that mixes real and virtual intermediates.
|
||||
*
|
||||
* The previous implementation derived the target's secret from
|
||||
* `BinaryTree.parent(target)` which, for leaves on the right edge of a
|
||||
* non-full tree, returned a high ancestor (often the root) that is NOT
|
||||
* the target's direct parent. Its `left()` and `right()` then pointed
|
||||
* at different nodes entirely, the target stayed un-cached, and the
|
||||
* follow-up `return treeSecrets[nodeIndex]!!` either threw NPE (on
|
||||
* JVM) or — when repeatedly re-entered — recursed into
|
||||
* [getNodeSecret] until the stack was exhausted (on ART).
|
||||
*/
|
||||
private fun getLeafSecret(leafIndex: Int): ByteArray {
|
||||
val nodeIndex = BinaryTree.leafToNode(leafIndex)
|
||||
return getNodeSecret(nodeIndex)
|
||||
}
|
||||
val targetNode = BinaryTree.leafToNode(leafIndex)
|
||||
val rootIdx = BinaryTree.root(leafCount)
|
||||
var currentSecret = encryptionSecret
|
||||
var currentNode = rootIdx
|
||||
|
||||
/**
|
||||
* Recursively derive a node's secret from its parent in the secret tree.
|
||||
*/
|
||||
private fun getNodeSecret(nodeIndex: Int): ByteArray {
|
||||
treeSecrets[nodeIndex]?.let { return it }
|
||||
while (currentNode != targetNode) {
|
||||
val goLeft = targetNode < currentNode
|
||||
val label = if (goLeft) "left" else "right"
|
||||
currentSecret =
|
||||
MlsCryptoProvider.expandWithLabel(
|
||||
currentSecret,
|
||||
"tree",
|
||||
label.encodeToByteArray(),
|
||||
MlsCryptoProvider.HASH_OUTPUT_LENGTH,
|
||||
)
|
||||
currentNode = if (goLeft) BinaryTree.left(currentNode) else BinaryTree.right(currentNode)
|
||||
}
|
||||
|
||||
val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
|
||||
val parentSecret = getNodeSecret(parentIdx)
|
||||
|
||||
// Derive left and right children secrets
|
||||
val leftIdx = BinaryTree.left(parentIdx)
|
||||
val rightIdx = BinaryTree.right(parentIdx)
|
||||
|
||||
val leftSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "left".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||
val rightSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "right".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||
|
||||
treeSecrets[leftIdx] = leftSecret
|
||||
treeSecrets[rightIdx] = rightSecret
|
||||
|
||||
// Clear parent secret for forward secrecy
|
||||
treeSecrets.remove(parentIdx)
|
||||
|
||||
return treeSecrets[nodeIndex]!!
|
||||
return currentSecret
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip53LiveActivities.clip
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-53 live stream clip (zap.stream convention, kind 1313).
|
||||
*
|
||||
* A clip is a standalone highlight produced from an ongoing or past live stream.
|
||||
* The event carries:
|
||||
* - `a` -> the source stream address (kind 30311)
|
||||
* - `p` -> the stream host's pubkey
|
||||
* - `r` -> direct playable video URL (MP4/HLS)
|
||||
* - `title` -> clip title
|
||||
* - `alt` -> NIP-31 fallback text
|
||||
*
|
||||
* `content` is an optional free-text caption.
|
||||
*/
|
||||
@Immutable
|
||||
class LiveActivitiesClipEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
AddressHintProvider,
|
||||
PubKeyHintProvider {
|
||||
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
|
||||
|
||||
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
|
||||
|
||||
fun activity(): ATag? =
|
||||
tags
|
||||
.asSequence()
|
||||
.mapNotNull(ATag::parse)
|
||||
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
|
||||
|
||||
fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
|
||||
|
||||
fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse)
|
||||
|
||||
fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 1313
|
||||
const val ALT = "Live activity clip"
|
||||
|
||||
/**
|
||||
* Builds an event template for a clip. Typically published by the clip-authoring backend
|
||||
* on behalf of a viewer, but can also be published directly by a client.
|
||||
*/
|
||||
fun build(
|
||||
activity: EventHintBundle<LiveActivitiesEvent>,
|
||||
videoUrl: String,
|
||||
title: String,
|
||||
host: HexKey = activity.event.pubKey,
|
||||
caption: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, caption, createdAt) {
|
||||
aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay))
|
||||
add(PTag.assemble(host, null))
|
||||
add(ReferenceTag.assemble(videoUrl))
|
||||
add(TitleTag.assemble(title))
|
||||
add(AltTag.assemble(ALT))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip53LiveActivities.raid
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-53 live stream raid (zap.stream convention, kind 1312).
|
||||
*
|
||||
* A raid is authored by the source streamer to redirect viewers to another live
|
||||
* stream. The event carries two `a` tags referencing NIP-53 Live Activities
|
||||
* (kind 30311) differentiated by NIP-10-style markers at position 3:
|
||||
* - "root" -> the source stream (the raid is being sent FROM)
|
||||
* - "mention" -> the target stream (the raid is being sent TO)
|
||||
*
|
||||
* The `content` is a free-text raid message from the source streamer.
|
||||
*/
|
||||
@Immutable
|
||||
class LiveActivitiesRaidEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
AddressHintProvider {
|
||||
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
|
||||
|
||||
fun fromActivity(): ATag? = findActivity(MARKER_ROOT)
|
||||
|
||||
fun toActivity(): ATag? = findActivity(MARKER_MENTION)
|
||||
|
||||
fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
|
||||
|
||||
fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
|
||||
|
||||
private fun findActivity(marker: String): ATag? =
|
||||
tags
|
||||
.asSequence()
|
||||
.filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker }
|
||||
.mapNotNull(ATag::parse)
|
||||
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
|
||||
|
||||
companion object {
|
||||
const val KIND = 1312
|
||||
const val ALT = "Live activity raid"
|
||||
const val MARKER_ROOT = "root"
|
||||
const val MARKER_MENTION = "mention"
|
||||
|
||||
/**
|
||||
* Builds an event template for a raid. Sender must be the host of the `from` stream.
|
||||
*
|
||||
* @param from source stream (the one currently live that is being ended/redirected)
|
||||
* @param to target stream (where viewers should be redirected)
|
||||
* @param message raid announcement text
|
||||
*/
|
||||
fun build(
|
||||
from: EventHintBundle<LiveActivitiesEvent>,
|
||||
to: EventHintBundle<LiveActivitiesEvent>,
|
||||
message: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveActivitiesRaidEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
addMarkedATag(from, MARKER_ROOT)
|
||||
addMarkedATag(to, MARKER_MENTION)
|
||||
initializer()
|
||||
}
|
||||
|
||||
private fun TagArrayBuilder<LiveActivitiesRaidEvent>.addMarkedATag(
|
||||
bundle: EventHintBundle<LiveActivitiesEvent>,
|
||||
marker: String,
|
||||
) {
|
||||
val relayUrl = bundle.relay?.url.orEmpty()
|
||||
val addressId =
|
||||
Address.assemble(
|
||||
LiveActivitiesEvent.KIND,
|
||||
bundle.event.pubKey,
|
||||
bundle.event.dTag(),
|
||||
)
|
||||
add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker))
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
|
||||
|
||||
fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
|
||||
|
||||
/**
|
||||
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
|
||||
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
|
||||
*/
|
||||
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
|
||||
|
||||
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
|
||||
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
|
||||
StatusTag.STATUS.ENDED
|
||||
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
|
||||
companion object {
|
||||
const val KIND = 30311
|
||||
const val ALT = "Live activity event"
|
||||
const val GOAL_TAG = "goal"
|
||||
|
||||
suspend fun create(
|
||||
signer: NostrSigner,
|
||||
|
||||
+2
-3
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
|
||||
fun TagArrayBuilder<CommunityPostApprovalEvent>.community(event: EventHintBundle<CommunityDefinitionEvent>) = add(event.toATag().toATagArray())
|
||||
@@ -37,6 +38,4 @@ fun TagArrayBuilder<CommunityPostApprovalEvent>.approved(event: EventHintBundle<
|
||||
}
|
||||
}
|
||||
|
||||
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) {
|
||||
add(event.toETagArray())
|
||||
}
|
||||
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay))
|
||||
|
||||
+6
-2
@@ -21,18 +21,22 @@
|
||||
package com.vitorpamplona.quartz.nip72ModCommunities.definition
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
|
||||
fun TagArrayBuilder<CommunityDefinitionEvent>.name(name: String) = addUnique(NameTag.assemble(name))
|
||||
|
||||
fun TagArrayBuilder<CommunityDefinitionEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||
|
||||
fun TagArrayBuilder<CommunityDefinitionEvent>.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl))
|
||||
fun TagArrayBuilder<CommunityDefinitionEvent>.image(
|
||||
webUrl: String,
|
||||
dimensions: DimensionTag? = null,
|
||||
) = addUnique(ImageTag.assemble(webUrl, dimensions))
|
||||
|
||||
fun TagArrayBuilder<CommunityDefinitionEvent>.rules(rules: String) = addUnique(RulesTag.assemble(rules))
|
||||
|
||||
|
||||
+4
@@ -35,6 +35,10 @@ class RelayTag(
|
||||
companion object {
|
||||
const val TAG_NAME = "relay"
|
||||
|
||||
const val MARKER_AUTHOR = "author"
|
||||
const val MARKER_REQUESTS = "requests"
|
||||
const val MARKER_APPROVALS = "approvals"
|
||||
|
||||
fun parse(tag: Array<String>): RelayTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
@@ -436,7 +438,9 @@ class EventFactory {
|
||||
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+26
-14
@@ -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(),
|
||||
)
|
||||
|
||||
+121
-13
@@ -115,27 +115,24 @@ class MarmotMipComplianceTest {
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() {
|
||||
// Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for
|
||||
// name/description/admins/relays/images, then disappearing_message_secs
|
||||
// = 8 bytes of zero (invalid).
|
||||
// Hand-crafted TLS blob (MIP-01 QUIC VarInt length prefixes):
|
||||
// uint16 version=3 | opaque group_id[32] | 8x empty VarInt(0) fields
|
||||
// (name..image_upload_key) | disappearing_message_secs = VarInt(8) + 8
|
||||
// zero bytes (invalid per MIP-01).
|
||||
val header =
|
||||
ByteArray(2 + 32) {
|
||||
// version + groupId
|
||||
when (it) {
|
||||
0 -> 0
|
||||
|
||||
1 -> 3
|
||||
|
||||
// version=3
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
// 8x opaque2 fields of length 0, each encoded as two zero bytes:
|
||||
// 8 empty VarInt-prefixed opaque fields, each a single 0x00 byte:
|
||||
// name, description, admin_pubkeys, relays, image_hash, image_key,
|
||||
// image_nonce, image_upload_key
|
||||
val zeroFields = ByteArray(8 * 2) // all zeros
|
||||
// disappearing_message_secs opaque2 with 8 zero bytes
|
||||
val disappearingField = ByteArray(2 + 8).also { it[1] = 8 }
|
||||
val zeroFields = ByteArray(8) // all 0x00
|
||||
// disappearing_message_secs: VarInt(8) = 0x08, then 8 zero bytes
|
||||
val disappearingField = ByteArray(1 + 8).also { it[0] = 0x08 }
|
||||
val blob = header + zeroFields + disappearingField
|
||||
|
||||
// decodeTls catches any exception and returns null
|
||||
@@ -150,13 +147,124 @@ class MarmotMipComplianceTest {
|
||||
it[0] = 0
|
||||
it[1] = 99
|
||||
}
|
||||
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
|
||||
val disappearingField = ByteArray(2) // zero-length
|
||||
val zeroFields = ByteArray(8) // 8x VarInt(0) for name..image_upload_key
|
||||
val disappearingField = ByteArray(1) // VarInt(0) — zero-length field
|
||||
|
||||
val blob = header + zeroFields + disappearingField
|
||||
|
||||
assertNull(MarmotGroupData.decodeTls(blob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_rejectsDuplicateAdminPubkeys() {
|
||||
// MIP-01: admin_pubkeys MUST NOT contain duplicate keys.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MarmotGroupData(
|
||||
nostrGroupId = groupId32,
|
||||
adminPubkeys = listOf(adminPubkey, adminPubkey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- MIP-01 byte-level interop fixtures (MDK reference) ----------------
|
||||
//
|
||||
// These fixtures were produced by serializing the identical struct via the
|
||||
// Rust `tls_codec` 0.4 crate used by MDK (see commit message for the
|
||||
// generator). They pin Amethyst's v2 encoder output byte-for-byte against
|
||||
// the MDK reference, so any future regression in VarInt framing surfaces
|
||||
// immediately.
|
||||
//
|
||||
// Fixtures are v2 (no `disappearing_message_secs` field) because MDK's
|
||||
// current `CURRENT_VERSION = 2` reference has no v3 support yet.
|
||||
|
||||
private fun mdkFixtureA(): ByteArray =
|
||||
(
|
||||
// version=2 + 32 bytes of group_id (all zero)
|
||||
"0002" +
|
||||
"00".repeat(32) +
|
||||
// name=empty, description=empty (VarInt(0) = single 0x00)
|
||||
"0000" +
|
||||
// admin_pubkeys: VarInt(32) = 0x20, then one 32-byte key of 0xAA
|
||||
"20" + "aa".repeat(32) +
|
||||
// relays: outer VarInt(21) = 0x15; inner VarInt(20) = 0x14 +
|
||||
// "wss://relay.example/" (20 bytes)
|
||||
"15" + "14" + "7773733a2f2f72656c61792e6578616d706c652f" +
|
||||
// image_hash, image_key, image_nonce, image_upload_key — all empty
|
||||
"00000000"
|
||||
).hexToByteArray()
|
||||
|
||||
private fun mdkFixtureB(): ByteArray =
|
||||
(
|
||||
"0002" +
|
||||
"11".repeat(32) +
|
||||
// name: VarInt(8)=0x08 + "Amethyst"
|
||||
"08" + "416d657468797374" +
|
||||
// description: VarInt(10)=0x0a + "Test group"
|
||||
"0a" + "546573742067726f7570" +
|
||||
// admin_pubkeys: outer VarInt(64) — 64 = 0x40, two-byte VarInt
|
||||
// prefix "40 40" (high bits 01, value 0x0040) + 2×32 bytes
|
||||
"4040" + "bb".repeat(32) + "cc".repeat(32) +
|
||||
// relays outer VarInt(44) = 0x2c, then two inner relays each
|
||||
// VarInt(21) + 21-byte URL
|
||||
"2c" +
|
||||
"15" + "7773733a2f2f72656c6179312e6578616d706c652f" +
|
||||
"15" + "7773733a2f2f72656c6179322e6578616d706c652f" +
|
||||
// image_* all empty
|
||||
"00000000"
|
||||
).hexToByteArray()
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_encodesFixtureAByteForByteVsMdk() {
|
||||
// Encode an Amethyst MarmotGroupData with the same inputs and assert the
|
||||
// bytes match MDK's tls_codec 0.4 output exactly.
|
||||
val data =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = "00".repeat(32),
|
||||
name = "",
|
||||
description = "",
|
||||
adminPubkeys = listOf("aa".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
)
|
||||
assertContentEquals(mdkFixtureA(), data.encodeTls())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_encodesFixtureBByteForByteVsMdk() {
|
||||
val data =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = "11".repeat(32),
|
||||
name = "Amethyst",
|
||||
description = "Test group",
|
||||
adminPubkeys = listOf("bb".repeat(32), "cc".repeat(32)),
|
||||
relays = listOf("wss://relay1.example/", "wss://relay2.example/"),
|
||||
)
|
||||
assertContentEquals(mdkFixtureB(), data.encodeTls())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_decodesMdkFixtureA() {
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureA()))
|
||||
assertEquals(2, decoded.version)
|
||||
assertEquals("00".repeat(32), decoded.nostrGroupId)
|
||||
assertEquals("", decoded.name)
|
||||
assertEquals("", decoded.description)
|
||||
assertEquals(listOf("aa".repeat(32)), decoded.adminPubkeys)
|
||||
assertEquals(listOf("wss://relay.example/"), decoded.relays)
|
||||
assertNull(decoded.disappearingMessageSecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_decodesMdkFixtureB() {
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureB()))
|
||||
assertEquals(2, decoded.version)
|
||||
assertEquals("Amethyst", decoded.name)
|
||||
assertEquals("Test group", decoded.description)
|
||||
assertEquals(listOf("bb".repeat(32), "cc".repeat(32)), decoded.adminPubkeys)
|
||||
assertEquals(listOf("wss://relay1.example/", "wss://relay2.example/"), decoded.relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
|
||||
val seed = "11".repeat(32).hexToByteArray()
|
||||
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+526
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -225,6 +226,87 @@ class MarmotPipelineTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAddMemberCommitIsFramedAsPublicMessage() {
|
||||
// Regression: addMember's outbound commit used to carry the raw Commit TLS
|
||||
// bytes instead of an MlsMessage(PublicMessage(commit)) envelope, which
|
||||
// caused receivers to fail parsing with "Unsupported MLS version: …"
|
||||
// when the first two bytes of the Commit struct were read as the
|
||||
// MlsMessage version field.
|
||||
runBlocking {
|
||||
val manager = createGroupManager()
|
||||
manager.createGroup(groupId, "alice".encodeToByteArray())
|
||||
val group = manager.getGroup(groupId)!!
|
||||
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||
|
||||
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||
|
||||
// The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit))
|
||||
val framed = commitResult.framedCommitBytes
|
||||
val mlsMessage =
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||
.decodeTls(
|
||||
com.vitorpamplona.quartz.marmot.mls.codec
|
||||
.TlsReader(framed),
|
||||
)
|
||||
assertEquals(
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
|
||||
mlsMessage.wireFormat,
|
||||
)
|
||||
|
||||
val publicMessage =
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
|
||||
.decodeTls(
|
||||
com.vitorpamplona.quartz.marmot.mls.codec
|
||||
.TlsReader(mlsMessage.payload),
|
||||
)
|
||||
assertEquals(
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT,
|
||||
publicMessage.contentType,
|
||||
)
|
||||
assertEquals(
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER,
|
||||
publicMessage.sender.senderType,
|
||||
)
|
||||
assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit")
|
||||
// The PublicMessage.content carries the raw Commit struct.
|
||||
kotlin.test.assertContentEquals(commitResult.commitBytes, publicMessage.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAddMemberCommitEventDecryptsToFramedMlsMessage() {
|
||||
// End-to-end variant: the kind:445 content returned by buildCommitEvent,
|
||||
// when ChaCha20-Poly1305 decrypted with the current exporter key, must
|
||||
// yield an MlsMessage whose wire format is PUBLIC_MESSAGE (not a raw
|
||||
// Commit struct).
|
||||
runBlocking {
|
||||
val manager = createGroupManager()
|
||||
manager.createGroup(groupId, "alice".encodeToByteArray())
|
||||
val group = manager.getGroup(groupId)!!
|
||||
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||
|
||||
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||
|
||||
val outbound = MarmotOutboundProcessor(manager)
|
||||
val outboundResult = outbound.buildCommitEvent(groupId, commitResult.framedCommitBytes)
|
||||
val event = outboundResult.signedEvent
|
||||
|
||||
val exporterKey = manager.exporterSecret(groupId)
|
||||
val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey)
|
||||
val mlsMessage =
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||
.decodeTls(
|
||||
com.vitorpamplona.quartz.marmot.mls.codec
|
||||
.TlsReader(mlsBytes),
|
||||
)
|
||||
assertEquals(
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
|
||||
mlsMessage.wireFormat,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubscriptionManagerSyncWithGroupManager() {
|
||||
runBlocking {
|
||||
@@ -346,6 +428,450 @@ class MarmotPipelineTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreatorDoesNotDoubleApplyOwnCommitAfterRestart() {
|
||||
// Root cause: after David restarts, his own add-member kind:445 that
|
||||
// got echoed back from the relay is no longer marked as "already
|
||||
// processed" (the dedup set lives in memory and isn't persisted).
|
||||
// The inbound path then outer-decrypts it via a retained epoch key
|
||||
// and feeds the commit into group.processCommit — which is NOT
|
||||
// atomic, so it partially advances David's local state before failing
|
||||
// with "Confirmation tag verification failed" (the tag was computed
|
||||
// at the original epoch; David is already two epochs past).
|
||||
//
|
||||
// After this partial apply, David's epoch / tree / exporter_secret
|
||||
// drift forward by one epoch while Eden and Fred stay put. Any
|
||||
// subsequent message David sends uses a key nobody else has, and
|
||||
// they log "Outer decryption failed with current and N retained
|
||||
// epoch key(s)".
|
||||
runBlocking {
|
||||
val davidStore = TestGroupStateStore()
|
||||
val davidMgr = MlsGroupManager(davidStore)
|
||||
val edenMgr = MlsGroupManager(TestGroupStateStore())
|
||||
val fredMgr = MlsGroupManager(TestGroupStateStore())
|
||||
val davidId = ByteArray(32) { 0xD1.toByte() }
|
||||
val edenId = ByteArray(32) { 0xE2.toByte() }
|
||||
val fredId = ByteArray(32) { 0xF3.toByte() }
|
||||
|
||||
davidMgr.createGroup(groupId, davidId)
|
||||
davidMgr.updateGroupExtensions(
|
||||
groupId,
|
||||
listOf(
|
||||
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
.MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
adminPubkeys = listOf(davidId.toHexKey()),
|
||||
).toExtension(),
|
||||
),
|
||||
)
|
||||
val davidGroup = davidMgr.getGroup(groupId)!!
|
||||
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
|
||||
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
|
||||
|
||||
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
|
||||
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
|
||||
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||
edenMgr.processCommit(
|
||||
groupId,
|
||||
addFred.commitBytes,
|
||||
davidMgr.getGroup(groupId)!!.leafIndex,
|
||||
ByteArray(0),
|
||||
)
|
||||
|
||||
val preRestartEpoch = davidMgr.getGroup(groupId)!!.epoch
|
||||
val preRestartExporter = davidMgr.exporterSecret(groupId)
|
||||
|
||||
// Simulate David's app restart.
|
||||
val davidMgr2 = MlsGroupManager(davidStore)
|
||||
davidMgr2.restoreAll()
|
||||
|
||||
// Now replay David's own echoed add-Eden commit that's still on
|
||||
// the relay. After restart, the inbound dedup set is empty so
|
||||
// the inbound pipeline does the full outer-decrypt via retained
|
||||
// keys + processCommit on an already-applied commit.
|
||||
val inbound =
|
||||
com.vitorpamplona.quartz.marmot
|
||||
.MarmotInboundProcessor(
|
||||
groupManager = davidMgr2,
|
||||
keyPackageRotationManager =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||
.KeyPackageRotationManager(),
|
||||
)
|
||||
val outbound = MarmotOutboundProcessor(davidMgr2)
|
||||
|
||||
// Re-encrypt the add-Eden framed commit with the pre-commit key
|
||||
// so it looks exactly like what the relay would re-deliver.
|
||||
val echoed =
|
||||
outbound.buildCommitEvent(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = addEden.framedCommitBytes,
|
||||
exporterKey = addEden.preCommitExporterSecret,
|
||||
)
|
||||
val result = inbound.processGroupEvent(echoed.signedEvent)
|
||||
assertIs<GroupEventResult.Duplicate>(
|
||||
result,
|
||||
"echoed already-applied commit must be treated as a no-op Duplicate",
|
||||
)
|
||||
|
||||
val postEchoEpoch = davidMgr2.getGroup(groupId)!!.epoch
|
||||
val postEchoExporter = davidMgr2.exporterSecret(groupId)
|
||||
|
||||
assertEquals(
|
||||
preRestartEpoch,
|
||||
postEchoEpoch,
|
||||
"processing our own already-applied commit echo must NOT advance the local epoch",
|
||||
)
|
||||
kotlin.test.assertContentEquals(
|
||||
preRestartExporter,
|
||||
postEchoExporter,
|
||||
"processing our own already-applied commit echo must NOT change the exporter secret",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreatorCanSendMessageAfterRestart() {
|
||||
// Reproduces production symptom: David creates a group, adds Eden and
|
||||
// Fred, sends messages — all fine. After David restarts the app
|
||||
// (simulated here by save-state + fresh MlsGroupManager + restoreAll),
|
||||
// his outbound kind:445 outer ChaCha20 layer suddenly uses a
|
||||
// different key than Eden/Fred, and they fail with
|
||||
// "Outer decryption failed with current and N retained epoch key(s)".
|
||||
runBlocking {
|
||||
val davidStore = TestGroupStateStore()
|
||||
val davidMgr = MlsGroupManager(davidStore)
|
||||
val edenMgr = MlsGroupManager(TestGroupStateStore())
|
||||
val fredMgr = MlsGroupManager(TestGroupStateStore())
|
||||
|
||||
val davidId = ByteArray(32) { 0xD1.toByte() }
|
||||
val edenId = ByteArray(32) { 0xE2.toByte() }
|
||||
val fredId = ByteArray(32) { 0xF3.toByte() }
|
||||
|
||||
davidMgr.createGroup(groupId, davidId)
|
||||
davidMgr.updateGroupExtensions(
|
||||
nostrGroupId = groupId,
|
||||
extensions =
|
||||
listOf(
|
||||
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
.MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
adminPubkeys = listOf(davidId.toHexKey()),
|
||||
).toExtension(),
|
||||
),
|
||||
)
|
||||
val davidGroup = davidMgr.getGroup(groupId)!!
|
||||
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
|
||||
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
|
||||
|
||||
// David adds Eden.
|
||||
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
|
||||
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
|
||||
|
||||
// David adds Fred; Eden processes Alice's add-Fred commit.
|
||||
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||
edenMgr.processCommit(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = addFred.commitBytes,
|
||||
senderLeafIndex = davidMgr.getGroup(groupId)!!.leafIndex,
|
||||
confirmationTag = ByteArray(0),
|
||||
)
|
||||
|
||||
// Pre-restart sanity: all three share the same outer exporter key.
|
||||
val preRestartDavidKey = davidMgr.exporterSecret(groupId)
|
||||
kotlin.test.assertContentEquals(
|
||||
preRestartDavidKey,
|
||||
edenMgr.exporterSecret(groupId),
|
||||
"Eden's exporter key should match David's before restart",
|
||||
)
|
||||
kotlin.test.assertContentEquals(
|
||||
preRestartDavidKey,
|
||||
fredMgr.exporterSecret(groupId),
|
||||
"Fred's exporter key should match David's before restart",
|
||||
)
|
||||
|
||||
// Simulate David's app restart: drop his in-memory manager and
|
||||
// rebuild it from the persisted store.
|
||||
val davidMgr2 = MlsGroupManager(davidStore)
|
||||
davidMgr2.restoreAll()
|
||||
|
||||
val postRestartDavidKey = davidMgr2.exporterSecret(groupId)
|
||||
kotlin.test.assertContentEquals(
|
||||
preRestartDavidKey,
|
||||
postRestartDavidKey,
|
||||
"David's exporter key must survive a save+restore round-trip; " +
|
||||
"otherwise Eden/Fred can't decrypt his next outer layer.",
|
||||
)
|
||||
|
||||
// David sends a message post-restart; Eden and Fred must decrypt.
|
||||
val outbound = MarmotOutboundProcessor(davidMgr2)
|
||||
val msg = "hi after restart"
|
||||
val postRestartEvent =
|
||||
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
|
||||
|
||||
val edenKey = edenMgr.exporterSecret(groupId)
|
||||
val mlsBytesEden = GroupEventEncryption.decrypt(postRestartEvent.signedEvent.content, edenKey)
|
||||
val edenDecrypted = edenMgr.decrypt(groupId, mlsBytesEden)
|
||||
assertEquals(msg, edenDecrypted.content.decodeToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFredEncryptsAfterJoiningThreeMemberGroup() {
|
||||
// Reproduces the production StackOverflowError: the last-joined member
|
||||
// (Fred at leafIndex=2 in a 3-member group) attempts to encrypt his
|
||||
// first message and the SecretTree.getNodeSecret recursion never
|
||||
// terminates.
|
||||
runBlocking {
|
||||
val aliceMgr = createGroupManager()
|
||||
val bobMgr = createGroupManager()
|
||||
val fredMgr = createGroupManager()
|
||||
|
||||
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
|
||||
aliceMgr.createGroup(groupId, aliceIdBytes)
|
||||
aliceMgr.updateGroupExtensions(
|
||||
nostrGroupId = groupId,
|
||||
extensions =
|
||||
listOf(
|
||||
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
.MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
|
||||
).toExtension(),
|
||||
),
|
||||
)
|
||||
val aliceGroup = aliceMgr.getGroup(groupId)!!
|
||||
val bobBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xB2.toByte() }, ByteArray(0))
|
||||
val fredBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xF3.toByte() }, ByteArray(0))
|
||||
|
||||
// Alice adds Bob.
|
||||
val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||
bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle)
|
||||
|
||||
// Alice adds Fred (he joins at leafIndex=2, in a 3-leaf tree).
|
||||
val addFred = aliceMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||
// Bob (existing member at the pre-add-Fred epoch) receives and
|
||||
// applies Alice's add-Fred commit to reach the same epoch as
|
||||
// Alice + Fred. Without this, Bob can't decrypt Fred's subsequent
|
||||
// application messages.
|
||||
bobMgr.processCommit(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = addFred.commitBytes,
|
||||
senderLeafIndex = aliceMgr.getGroup(groupId)!!.leafIndex,
|
||||
confirmationTag = ByteArray(0),
|
||||
)
|
||||
|
||||
// Sanity: Fred is at leafIndex=2, leafCount=3.
|
||||
assertEquals(2, fredMgr.getGroup(groupId)!!.leafIndex)
|
||||
|
||||
// This is where production crashed with StackOverflowError.
|
||||
val fredMsg = "hi from fred"
|
||||
val fredCiphertext = fredMgr.encrypt(groupId, fredMsg.encodeToByteArray())
|
||||
|
||||
// And Alice & Bob must be able to decrypt it.
|
||||
val aliceDecrypted = aliceMgr.decrypt(groupId, fredCiphertext)
|
||||
assertEquals(fredMsg, aliceDecrypted.content.decodeToString())
|
||||
val bobDecrypted = bobMgr.decrypt(groupId, fredCiphertext)
|
||||
assertEquals(fredMsg, bobDecrypted.content.decodeToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAddMemberExposesPreCommitExporterSecret() {
|
||||
// Regression: before the fix, MarmotManager.addMember outer-encrypted
|
||||
// the kind:445 commit with the POST-commit (epoch N+1) exporter key,
|
||||
// which meant existing members at epoch N couldn't decrypt it. The
|
||||
// fix captures the pre-commit key inside MlsGroup.commit() and ships
|
||||
// it back via CommitResult.preCommitExporterSecret so MarmotManager
|
||||
// can pass it to the outbound builder.
|
||||
runBlocking {
|
||||
val manager = createGroupManager()
|
||||
manager.createGroup(groupId, "alice".encodeToByteArray())
|
||||
|
||||
val preEpochExporter = manager.exporterSecret(groupId)
|
||||
val preEpoch = manager.getGroup(groupId)!!.epoch
|
||||
|
||||
val bobBundle =
|
||||
manager
|
||||
.getGroup(groupId)!!
|
||||
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||
val result = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||
|
||||
// Local state advanced to N+1.
|
||||
assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch)
|
||||
// But the returned pre-commit exporter secret matches what the
|
||||
// group held BEFORE advancing — the key existing members still at
|
||||
// epoch N are holding.
|
||||
kotlin.test.assertContentEquals(
|
||||
preEpochExporter,
|
||||
result.preCommitExporterSecret,
|
||||
)
|
||||
// The post-commit exporter (what groupManager.exporterSecret now
|
||||
// returns) MUST differ — otherwise we haven't actually rotated.
|
||||
kotlin.test.assertFalse(
|
||||
manager.exporterSecret(groupId).contentEquals(preEpochExporter),
|
||||
"post-commit exporter key must differ from the pre-commit one",
|
||||
)
|
||||
assertNotNull(result.welcomeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage() {
|
||||
// End-to-end regression for the David/Eden/Fred scenario: a group
|
||||
// creator (Alice) adds two members (Bob then Carol) in sequence and
|
||||
// then publishes an application message. Both members MUST be able to
|
||||
// decrypt the message. Before the stage/merge fix, the add-Carol
|
||||
// commit was outer-encrypted with the post-commit (epoch 2) key,
|
||||
// leaving Bob stuck at epoch 1 and unable to decrypt any of Alice's
|
||||
// subsequent application messages.
|
||||
runBlocking {
|
||||
val aliceMgr = createGroupManager()
|
||||
val bobMgr = createGroupManager()
|
||||
val carolMgr = createGroupManager()
|
||||
|
||||
// Use 32-byte identities so they fit the MarmotGroupData
|
||||
// admin_pubkeys 32-byte slots.
|
||||
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
|
||||
val bobIdBytes = ByteArray(32) { 0xB2.toByte() }
|
||||
val carolIdBytes = ByteArray(32) { 0xC3.toByte() }
|
||||
aliceMgr.createGroup(groupId, aliceIdBytes)
|
||||
// Install the Marmot group data extension so the Welcome carries
|
||||
// the NostrGroupData (MlsGroupManager.processWelcome requires it).
|
||||
aliceMgr.updateGroupExtensions(
|
||||
nostrGroupId = groupId,
|
||||
extensions =
|
||||
listOf(
|
||||
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
.MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
|
||||
).toExtension(),
|
||||
),
|
||||
)
|
||||
val aliceGroup = aliceMgr.getGroup(groupId)!!
|
||||
|
||||
// KeyPackages are just MLS artifacts carrying identity + init key;
|
||||
// createKeyPackage on any group instance produces a usable bundle.
|
||||
val bobBundle = aliceGroup.createKeyPackage(bobIdBytes, ByteArray(0))
|
||||
val carolBundle = aliceGroup.createKeyPackage(carolIdBytes, ByteArray(0))
|
||||
|
||||
val outbound = MarmotOutboundProcessor(aliceMgr)
|
||||
|
||||
// --- Step 1: Alice adds Bob. CommitResult carries the pre-commit
|
||||
// exporter secret so buildCommitEvent can outer-encrypt with
|
||||
// the epoch-N (pre-Bob) key that Bob-less Alice held. ---
|
||||
val addBobResult = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||
val addBobCommitEvent =
|
||||
outbound.buildCommitEvent(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = addBobResult.framedCommitBytes,
|
||||
exporterKey = addBobResult.preCommitExporterSecret,
|
||||
)
|
||||
|
||||
// Bob joins via Welcome (emulated: we hand him the Welcome bytes).
|
||||
bobMgr.processWelcome(addBobResult.welcomeBytes!!, bobBundle)
|
||||
|
||||
val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch
|
||||
assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch)
|
||||
|
||||
// --- Step 2: Alice adds Carol. addCarolResult.preCommitExporterSecret
|
||||
// is the epoch-N (2-member) key that Bob still holds. ---
|
||||
val addCarolResult = aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes())
|
||||
val addCarolCommitEvent =
|
||||
outbound.buildCommitEvent(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = addCarolResult.framedCommitBytes,
|
||||
exporterKey = addCarolResult.preCommitExporterSecret,
|
||||
)
|
||||
|
||||
// Carol joins via Welcome at the new epoch.
|
||||
carolMgr.processWelcome(addCarolResult.welcomeBytes!!, carolBundle)
|
||||
|
||||
// *** The key assertion ***: Bob (still at epoch 1) receives the
|
||||
// add-Carol kind:445 and must be able to:
|
||||
// (a) outer-decrypt using his current (epoch 1) exporter key,
|
||||
// (b) parse the MlsMessage → PublicMessage → COMMIT,
|
||||
// (c) apply the commit and advance to epoch 2.
|
||||
val bobExporterAtE1 = bobMgr.exporterSecret(groupId)
|
||||
kotlin.test.assertContentEquals(
|
||||
bobExporterAtE1,
|
||||
addCarolResult.preCommitExporterSecret,
|
||||
"Bob's current exporter (pre-add-Carol) must match the " +
|
||||
"pre-commit key used to outer-encrypt the add-Carol commit; " +
|
||||
"otherwise Bob cannot decrypt and will be stuck on the old epoch.",
|
||||
)
|
||||
|
||||
val decryptedMlsBytes =
|
||||
GroupEventEncryption.decrypt(
|
||||
addCarolCommitEvent.signedEvent.content,
|
||||
bobExporterAtE1,
|
||||
)
|
||||
val mlsMessage =
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||
.decodeTls(
|
||||
com.vitorpamplona.quartz.marmot.mls.codec
|
||||
.TlsReader(decryptedMlsBytes),
|
||||
)
|
||||
val publicMessage =
|
||||
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
|
||||
.decodeTls(
|
||||
com.vitorpamplona.quartz.marmot.mls.codec
|
||||
.TlsReader(mlsMessage.payload),
|
||||
)
|
||||
bobMgr.processCommit(
|
||||
nostrGroupId = groupId,
|
||||
commitBytes = publicMessage.content,
|
||||
senderLeafIndex = publicMessage.sender.leafIndex,
|
||||
confirmationTag = publicMessage.confirmationTag!!,
|
||||
)
|
||||
|
||||
val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch
|
||||
assertEquals(epochAfterAddBob + 1, epochAfterAddCarol)
|
||||
assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch)
|
||||
assertEquals(epochAfterAddCarol, carolMgr.getGroup(groupId)!!.epoch)
|
||||
|
||||
// --- Step 3: Alice sends an application message. Both members must decrypt. ---
|
||||
val msg = "Hi from Alice"
|
||||
val appEvent =
|
||||
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
|
||||
|
||||
val bobKey = bobMgr.exporterSecret(groupId)
|
||||
val carolKey = carolMgr.exporterSecret(groupId)
|
||||
val aliceKey = aliceMgr.exporterSecret(groupId)
|
||||
kotlin.test.assertContentEquals(aliceKey, bobKey, "Bob must share Alice's epoch-2 key")
|
||||
kotlin.test.assertContentEquals(aliceKey, carolKey, "Carol must share Alice's epoch-2 key")
|
||||
|
||||
val bobMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, bobKey)
|
||||
val bobDecrypted = bobMgr.decrypt(groupId, bobMlsBytes)
|
||||
assertEquals(msg, bobDecrypted.content.decodeToString())
|
||||
|
||||
val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey)
|
||||
val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes)
|
||||
assertEquals(msg, carolDecrypted.content.decodeToString())
|
||||
|
||||
// --- Alice also receives her own echo (as the app does via the
|
||||
// relay round-trip); this exercises the secretTree-based decrypt
|
||||
// path with myLeafIndex != sender or when sentKeys misses.
|
||||
val aliceMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, aliceKey)
|
||||
val aliceDecrypted = aliceMgr.decrypt(groupId, aliceMlsBytes)
|
||||
assertEquals(msg, aliceDecrypted.content.decodeToString())
|
||||
|
||||
// Send a second and third message to stress the secretTree ratchet
|
||||
// and ensure getNodeSecret stays terminating across generations.
|
||||
for (i in 1..3) {
|
||||
val m = "msg-$i"
|
||||
val ev = outbound.buildGroupEventFromBytes(groupId, m.encodeToByteArray())
|
||||
val keyNow = aliceMgr.exporterSecret(groupId)
|
||||
val bytes = GroupEventEncryption.decrypt(ev.signedEvent.content, keyNow)
|
||||
val dec = bobMgr.decrypt(groupId, bytes)
|
||||
assertEquals(m, dec.content.decodeToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFullRoundtripEncryptDecrypt() {
|
||||
runBlocking {
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
+5
-7
@@ -176,7 +176,6 @@ class MlsGroupLifecycleTest {
|
||||
// because the commit_secret decryption from the UpdatePath does not
|
||||
// correctly walk the ratchet tree to find the common ancestor's path secret.
|
||||
// This causes AEAD decryption failures on cross-member messages.
|
||||
@Ignore
|
||||
@Test
|
||||
fun testThreeMemberGroup_SequentialAdditions() {
|
||||
// Alice creates the group
|
||||
@@ -243,7 +242,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -266,7 +264,6 @@ class MlsGroupLifecycleTest {
|
||||
}
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testExternalJoin_ExporterSecretsAgree() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -312,7 +309,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testSigningKeyRotation_EpochAdvances() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -342,7 +338,6 @@ class MlsGroupLifecycleTest {
|
||||
}
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testEncryptDecryptAfterSigningKeyRotation() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -412,7 +407,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testPskProposal_EpochAdvancesWithPsk() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -472,7 +466,11 @@ class MlsGroupLifecycleTest {
|
||||
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
// BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and
|
||||
// Bob's exporter secrets after processCommit. Unrelated to the SecretTree
|
||||
// non-full-tree fix — the other @Ignored "processCommit diverges" tests in
|
||||
// this file and MlsGroupEdgeCaseTest now pass, but this one still fails.
|
||||
// Tracked separately.
|
||||
@Ignore
|
||||
@Test
|
||||
fun testEmptyCommit_AdvancesEpoch() {
|
||||
|
||||
+125
-9
@@ -175,21 +175,45 @@ jq_group_id() {
|
||||
# wait_for_invite <B|C> <timeout-seconds>
|
||||
# Echoes the first new group_id that appears in `wn groups invites --json`.
|
||||
# Returns 1 on timeout.
|
||||
#
|
||||
# Also prints a heartbeat every ~10s with:
|
||||
# - elapsed/remaining time
|
||||
# - current count of pending welcomes (should be 0 until it isn't)
|
||||
# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error)
|
||||
# so a stalled poll gives the operator something to forward.
|
||||
wait_for_invite() {
|
||||
local who="$1" timeout="${2:-60}" deadline gid
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
local who="$1" timeout="${2:-60}" deadline gid start last_hb
|
||||
local wnfn data_dir
|
||||
if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR"
|
||||
else wnfn=wn_c; data_dir="$C_DIR"; fi
|
||||
start=$(date +%s)
|
||||
deadline=$(( start + timeout ))
|
||||
last_hb=$start
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ "$who" == "B" ]]; then
|
||||
gid=$(wn_b_json groups invites 2>/dev/null \
|
||||
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
|
||||
else
|
||||
gid=$(wn_c_json groups invites 2>/dev/null \
|
||||
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
|
||||
fi
|
||||
gid=$("$wnfn" --json groups invites 2>/dev/null \
|
||||
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true)
|
||||
if [[ -n "${gid:-}" ]]; then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Heartbeat every ~10s.
|
||||
local now=$(date +%s)
|
||||
if (( now - last_hb >= 10 )); then
|
||||
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
|
||||
local pending
|
||||
pending=$("$wnfn" --json groups invites 2>/dev/null \
|
||||
| jq 'length' 2>/dev/null || echo "?")
|
||||
local recent=""
|
||||
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
||||
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
|
||||
| grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \
|
||||
| tail -n 1 || true)
|
||||
fi
|
||||
info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending tail: ${recent:-<no relevant wnd log yet>}"
|
||||
last_hb=$now
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
@@ -297,6 +321,98 @@ load_state() {
|
||||
grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2-
|
||||
}
|
||||
|
||||
# ------- daemon diagnostics --------------------------------------------------
|
||||
# Dumps everything we know about a daemon's state. Called before polling for
|
||||
# invites (baseline) and on every invite-poll failure so the operator can
|
||||
# forward a single log file that explains what the daemon saw (or didn't).
|
||||
#
|
||||
# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward).
|
||||
dump_daemon_diagnostics() {
|
||||
local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex
|
||||
if [[ "$who" == "B" ]]; then
|
||||
data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b
|
||||
npub="${B_NPUB:-?}"; hex="${B_HEX:-?}"
|
||||
else
|
||||
data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c
|
||||
npub="${C_NPUB:-?}"; hex="${C_HEX:-?}"
|
||||
fi
|
||||
|
||||
printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2
|
||||
printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE"
|
||||
|
||||
info "$who identity npub=$npub"
|
||||
info "$who identity hex =$hex"
|
||||
info "$who socket $socket"
|
||||
|
||||
# Relay list grouped by type — this is the critical bit for invite
|
||||
# debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only,
|
||||
# so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these
|
||||
# URLs, the gift wrap will never reach B.
|
||||
step "$who relays (per type):"
|
||||
for t in nip65 inbox key_package; do
|
||||
local raw urls
|
||||
raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true)
|
||||
urls=$(printf '%s' "$raw" \
|
||||
| jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \
|
||||
| paste -sd',' -)
|
||||
info " $t: ${urls:-<none>}"
|
||||
printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE"
|
||||
done
|
||||
|
||||
# Connection status per relay — shows which sockets are actually live.
|
||||
step "$who relay connection status:"
|
||||
"$wnfn" --json relays list 2>/dev/null \
|
||||
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
|
||||
| tee -a "$LOG_FILE" >&2 || true
|
||||
|
||||
# Daemon-level subscription health.
|
||||
step "$who wn debug health:"
|
||||
local health
|
||||
health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true)
|
||||
printf ' %s\n' "${health:-<no output>}" | tee -a "$LOG_FILE" >&2
|
||||
|
||||
# Relay-control state (subscriptions, filters, planes). Truncated to keep
|
||||
# stderr readable; full copy still goes to $LOG_FILE.
|
||||
step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):"
|
||||
local rcs
|
||||
rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true)
|
||||
printf '%s\n' "${rcs:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
|
||||
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$LOG_FILE"
|
||||
|
||||
# Pending welcomes already decrypted by the daemon. Non-empty here while
|
||||
# wait_for_invite is polling would be a race worth noticing.
|
||||
step "$who pending invites:"
|
||||
local inv
|
||||
inv=$("$wnfn" --json groups invites 2>/dev/null || true)
|
||||
printf ' %s\n' "${inv:-<no output>}" | tee -a "$LOG_FILE" >&2
|
||||
|
||||
# Tail of daemon stderr — contains the live 1059/welcome processing logs.
|
||||
step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):"
|
||||
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
||||
tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true
|
||||
else
|
||||
info " (no stderr log at $data_dir/logs/stderr.log)"
|
||||
fi
|
||||
|
||||
printf '%s%s---- end %s diagnostics ----%s\n\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2
|
||||
printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log
|
||||
# ends up with both sides of the pipe in one place.
|
||||
prompt_amethyst_logcat() {
|
||||
local note="${1:-paste below}"
|
||||
prompt_human "On the host running adb, capture Amethyst's Marmot logs:
|
||||
|
||||
adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\
|
||||
| tail -n 200 > /tmp/amethyst-marmot.log
|
||||
|
||||
Then paste /tmp/amethyst-marmot.log contents here so the failure report has
|
||||
both the Amethyst-side publish log and the wn-side subscription log ($note)."
|
||||
}
|
||||
|
||||
# ------- group cleanup -------------------------------------------------------
|
||||
cleanup_group() {
|
||||
local gid="$1" who="${2:-B}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user