diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 2f226fab7a..caba2542f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -84,6 +84,10 @@ val DefaultSignerPermissions = sealed class TopFilter( val code: String, ) { + interface AddressableTopFilter { + val address: Address + } + @Serializable object Global : TopFilter(" Global ") @@ -104,18 +108,21 @@ sealed class TopFilter( @Serializable class PeopleList( - val address: Address, - ) : TopFilter(address.toValue()) + override val address: Address, + ) : TopFilter(address.toValue()), + AddressableTopFilter @Serializable class MuteList( - val address: Address, - ) : TopFilter(address.toValue()) + override val address: Address, + ) : TopFilter(address.toValue()), + AddressableTopFilter @Serializable class Community( - val address: Address, - ) : TopFilter("Community/${address.toValue()}") + override val address: Address, + ) : TopFilter("Community/${address.toValue()}"), + AddressableTopFilter @Serializable class Hashtag( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c345bbeb78..d1c8321210 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -116,6 +116,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -1030,14 +1031,9 @@ object LocalCache : ILocalCache, ICacheProvider { event.taggedAddresses().map { getOrCreateAddressableNote(it) } } - is AcceptedBadgeSetEvent -> { - event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } - } - - is ProfileBadgesEvent -> { - event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } + is AcceptedBadgeSetEvent, is ProfileBadgesEvent -> { + event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } } is BadgeAwardEvent -> { @@ -1048,17 +1044,11 @@ object LocalCache : ILocalCache, ICacheProvider { event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } } - is RepostEvent -> { + is RepostEvent, is GenericRepostEvent -> { + val repost = event as BaseRepostEvent listOfNotNull( - event.boostedEventId()?.let { checkGetOrCreateNote(it) }, - event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, - ) - } - - is GenericRepostEvent -> { - listOfNotNull( - event.boostedEventId()?.let { checkGetOrCreateNote(it) }, - event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, + repost.boostedEventId()?.let { checkGetOrCreateNote(it) }, + repost.boostedAddress()?.let { getOrCreateAddressableNote(it) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 8d09d78c3e..0292c1f151 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -96,38 +96,11 @@ class FeedTopNavFilterState( AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) } - is TopFilter.Community -> { + is TopFilter.Community, is TopFilter.PeopleList, is TopFilter.MuteList -> { + val addressFilter = listName as TopFilter.AddressableTopFilter NoteFeedFlow( LocalCache - .getOrCreateAddressableNote(listName.address) - .flow() - .metadata.stateFlow, - signer, - followsRelays, - blockedRelays, - proxyRelays, - caches, - ) - } - - is TopFilter.PeopleList -> { - NoteFeedFlow( - LocalCache - .getOrCreateAddressableNote(listName.address) - .flow() - .metadata.stateFlow, - signer, - followsRelays, - blockedRelays, - proxyRelays, - caches, - ) - } - - is TopFilter.MuteList -> { - NoteFeedFlow( - LocalCache - .getOrCreateAddressableNote(listName.address) + .getOrCreateAddressableNote(addressFilter.address) .flow() .metadata.stateFlow, signer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index cb9b0377c6..3f4c9c6483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -206,15 +206,9 @@ class NotificationReplyReceiver : BroadcastReceiver() { ) } - is CommentEvent -> { - CommentEvent.replyBuilder( - msg = replyText, - replyingTo = EventHintBundle(targetEvent), - ) - } - else -> { - // Non-threaded events (e.g. long-form articles) use NIP-22 comments. + // NIP-22 CommentEvent and other non-threaded events (e.g. long-form articles) + // both reply via NIP-22 comments. CommentEvent.replyBuilder( msg = replyText, replyingTo = EventHintBundle(targetEvent), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt index 4734d7a697..c5d43c0954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt @@ -55,36 +55,25 @@ class CurrentPlayPositionCacher( override fun onPlaybackStateChanged(playbackState: Int) { currentUrl?.let { uri -> - when (playbackState) { - Player.STATE_IDLE -> { - // only saves if it wqs playing - if (!isLiveStreaming && abs(player.currentPosition) > 1) { - cache.add(uri, player.currentPosition) - } - } - - Player.STATE_READY -> { - if (!isLiveStreaming) { - cache.get(uri)?.let { lastPosition -> - // Restore the saved position only if it's meaningfully far from - // the player's current position. Position values are in - // milliseconds, so the previous `5 * 60` constant was a 300 ms - // threshold — small enough to trigger a seek (and an extra buffer - // flush right at playback start) for almost any saved position. - // 5 s gives the user a perceptible "resumed where I left off" - // without forcing a re-seek for trivially short clips. - if (abs(player.currentPosition - lastPosition) > 5_000) { - player.seekTo(lastPosition) - } + if (playbackState == Player.STATE_READY) { + if (!isLiveStreaming) { + cache.get(uri)?.let { lastPosition -> + // Restore the saved position only if it's meaningfully far from + // the player's current position. Position values are in + // milliseconds, so the previous `5 * 60` constant was a 300 ms + // threshold — small enough to trigger a seek (and an extra buffer + // flush right at playback start) for almost any saved position. + // 5 s gives the user a perceptible "resumed where I left off" + // without forcing a re-seek for trivially short clips. + if (abs(player.currentPosition - lastPosition) > 5_000) { + player.seekTo(lastPosition) } } } - - else -> { - // only saves if it wqs playing - if (!isLiveStreaming && abs(player.currentPosition) > 1) { - cache.add(uri, player.currentPosition) - } + } else { + // STATE_IDLE / STATE_BUFFERING / STATE_ENDED: only saves if it was playing. + if (!isLiveStreaming && abs(player.currentPosition) > 1) { + cache.add(uri, player.currentPosition) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt index c5f7abaeb4..8123ca0514 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt @@ -197,17 +197,10 @@ class CallSession( ensureForegroundService() } - is CallState.Connecting -> { - audioManager.stopRinging() - audioManager.stopRingbackTone() - withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } - audioManager.acquireProximityWakeLock() - CallNotifier.cancelIncomingCall(context) - updateForegroundServiceNotification() - registerNetworkCallback() - } - - is CallState.Connected -> { + is CallState.Connecting, is CallState.Connected -> { + // Same setup on both entry paths: Offering -> Connecting -> Connected, + // and direct -> Connected (e.g. answered-elsewhere). All ops below are + // idempotent so re-running on Connecting -> Connected is safe. audioManager.stopRinging() audioManager.stopRingbackTone() withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index e886de43b4..6d90c38983 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -50,6 +49,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -166,14 +166,9 @@ fun routeForInner( Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } - is GiftWrapEvent -> { - noteEvent.innerEventId?.let { - routeFor(LocalCache.getOrCreateNote(it), loggedIn) - } - } - - is SealedRumorEvent -> { - noteEvent.innerEventId?.let { + is GiftWrapEvent, is SealedRumorEvent -> { + val wrap = noteEvent as HasInnerEvent + wrap.innerEventId?.let { routeFor(LocalCache.getOrCreateNote(it), loggedIn) } } @@ -309,17 +304,9 @@ fun routeReplyTo( Route.NewShortNote(baseReplyTo = note.idHex) } - is PrivateDmEvent -> { - routeToMessage( - room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), - draftMessage = null, - replyId = noteEvent.id, - draftId = null, - account = account, - ) - } - is ChatroomKeyable -> { + // Covers PrivateDmEvent (NIP-04) and BaseDMGroupEvent (NIP-17) — both + // implement ChatroomKeyable with identical routing semantics here. routeToMessage( room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), draftMessage = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 19530a8b2b..1afd463fdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName import com.vitorpamplona.amethyst.ui.screen.InterestSetName import com.vitorpamplona.amethyst.ui.screen.Name +import com.vitorpamplona.amethyst.ui.screen.NoteBackedName import com.vitorpamplona.amethyst.ui.screen.PeopleListName import com.vitorpamplona.amethyst.ui.screen.RelayName import com.vitorpamplona.amethyst.ui.screen.ResourceName @@ -297,20 +298,9 @@ fun RenderOption( // Note-backed names: subscribe to the note so the displayed title updates as // the corresponding event arrives from relays. The displayed string itself is // produced by Name.name(), which already has the right precedence rules. - is PeopleListName -> { - val noteState by observeNote(option.note, accountViewModel) - val name = remember(noteState) { option.name(context) } - Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is CommunityName -> { - val noteState by observeNote(option.note, accountViewModel) - val name = remember(noteState) { option.name(context) } - Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is FavoriteAlgoFeedName -> { - val noteState by observeNote(option.note, accountViewModel) + is PeopleListName, is CommunityName, is FavoriteAlgoFeedName -> { + val backed = option as NoteBackedName + val noteState by observeNote(backed.note, accountViewModel) val name = remember(noteState) { option.name(context) } Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index a6c5825ae1..9403b39435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -465,16 +465,9 @@ fun RenderNIP05VerifiedSymbol( ) { CrossfadeIfEnabled(targetState = state, accountViewModel = accountViewModel) { when (it) { - is Nip05VerifState.Verifying -> { - Icon( - symbol = MaterialSymbols.Downloading, - contentDescription = stringRes(id = R.string.nip05_checking), - modifier = modifier, - tint = Color.Yellow, - ) - } - - is Nip05VerifState.NotStarted -> { + is Nip05VerifState.Verifying, is Nip05VerifState.NotStarted -> { + // Treat "not started" and "in flight" as a single loading visual so the + // badge doesn't blank-flash before the first check fires. Icon( symbol = MaterialSymbols.Downloading, contentDescription = stringRes(id = R.string.nip05_checking), @@ -492,16 +485,9 @@ fun RenderNIP05VerifiedSymbol( ) } - is Nip05VerifState.Failed -> { - Icon( - symbol = MaterialSymbols.Report, - contentDescription = stringRes(id = R.string.nip05_failed), - modifier = modifier, - tint = Color.Red, - ) - } - - is Nip05VerifState.Error -> { + is Nip05VerifState.Failed, is Nip05VerifState.Error -> { + // Failed (pubkey mismatch) and Error (HTTP/network) carry distinct retry + // timing but look the same to the user — both mean "badge isn't showing". Icon( symbol = MaterialSymbols.Report, contentDescription = stringRes(id = R.string.nip05_failed), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e9ebff83ad..412d16847b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -238,7 +238,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -1271,20 +1270,6 @@ private fun RenderNoteRow( ) } - is CommentEvent -> { - RenderTextEvent( - baseNote, - makeItShort, - canPreview, - quotesLeft, - unPackReply, - backgroundColor, - editState, - accountViewModel, - nav, - ) - } - is NIP90ContentDiscoveryResponseEvent -> { RenderNIP90ContentDiscoveryResponse( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 00931ee66e..37ef8ea4e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -326,6 +326,15 @@ sealed class Name { open fun name(context: Context) = name() } +/** + * Names whose displayed title is derived from a Nostr note (community + * definition, people list, DVM algo feed). UI layers can subscribe to + * the underlying `note` so the title updates as the event arrives. + */ +interface NoteBackedName { + val note: AddressableNote +} + @Stable class GeoHashName( val geoHashTag: String, @@ -358,8 +367,9 @@ class ResourceName( @Stable class PeopleListName( - val note: AddressableNote, -) : Name() { + override val note: AddressableNote, +) : Name(), + NoteBackedName { override fun name(): String { val noteEvent = note.event return if (noteEvent is PeopleListEvent) { @@ -374,8 +384,9 @@ class PeopleListName( @Stable class CommunityName( - val note: AddressableNote, -) : Name() { + override val note: AddressableNote, +) : Name(), + NoteBackedName { override fun name(): String { val definition = note.event as? CommunityDefinitionEvent val label = definition?.name()?.ifBlank { null } ?: note.dTag() @@ -385,8 +396,9 @@ class CommunityName( @Stable class FavoriteAlgoFeedName( - val note: AddressableNote, -) : Name() { + override val note: AddressableNote, +) : Name(), + NoteBackedName { override fun name(): String = (note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() } ?: note.dTag() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 480757da65..0443981f37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -264,16 +264,14 @@ open class ChannelNewMessageViewModel : wantsForwardZapTo = true } - if (draftEvent as? ChannelMessageEvent != null) { - val replyId = draftEvent.reply()?.eventId - if (replyId != null) { - replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) - } - } else if (draftEvent as? LiveActivitiesChatMessageEvent != null) { - val replyId = draftEvent.reply()?.eventId - if (replyId != null) { - replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) - } + // Both event kinds extend BaseThreadedEvent and share `reply()`. Cast through + // both candidates so the elvis result lands on the common supertype, then + // load the addressed reply note in a single block. + val threadedDraft = + (draftEvent as? ChannelMessageEvent) + ?: (draftEvent as? LiveActivitiesChatMessageEvent) + threadedDraft?.reply()?.eventId?.let { replyId -> + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) } message.setTextAndPlaceCursorAtEnd(draftEvent.content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index e7022890db..16e0941d2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -133,15 +133,11 @@ private fun ChatroomEntry( val baseNoteEvent = lastMessage.event when (baseNoteEvent) { - is ChannelMessageEvent -> { - baseNoteEvent.channelId()?.let { - LoadPublicChatChannel(it, accountViewModel) { channel -> - ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) - } - } - } - - is ChannelMetadataEvent -> { + // ChannelMessageEvent and ChannelMetadataEvent both expose channelId() via + // IsInPublicChatChannel and render identically here. Matched explicitly (rather + // than on the IsInPublicChatChannel interface) so the channel-admin events + // ChannelHideMessageEvent/ChannelMuteUserEvent keep falling through to `else`. + is ChannelMessageEvent, is ChannelMetadataEvent -> { baseNoteEvent.channelId()?.let { LoadPublicChatChannel(it, accountViewModel) { channel -> ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt index 709e3921e3..4f7e28c62a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -69,9 +69,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutabl import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import kotlinx.collections.immutable.ImmutableList @@ -101,23 +101,12 @@ fun RenderLiveActivityThumb( ) { val card by observeNoteAndMap(baseNote, accountViewModel) { when (val noteEvent = it.event) { - is LiveActivitiesEvent -> { + is LiveStreamLike -> { + // Both LiveActivitiesEvent (kind 30311) and MeetingRoomEvent (kind 30312) + // expose the same NIP-53 streaming tags via LiveStreamLike. address()/dTag() + // come from the shared BaseAddressableEvent parent. LiveActivityCard( - id = noteEvent.address(), - name = noteEvent.dTag(), - cover = noteEvent.image()?.ifBlank { null }, - media = noteEvent.streaming(), - subject = noteEvent.title()?.ifBlank { null }, - content = noteEvent.summary(), - participants = noteEvent.participants().toImmutableList(), - status = noteEvent.status(), - starts = noteEvent.starts(), - ) - } - - is MeetingRoomEvent -> { - LiveActivityCard( - id = noteEvent.address(), + id = (noteEvent as BaseAddressableEvent).address(), name = noteEvent.dTag(), cover = noteEvent.image()?.ifBlank { null }, media = noteEvent.streaming(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 4e2c151cd3..108ef90cf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessageText import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage @@ -1016,17 +1017,9 @@ private fun RenderDebugMessage(msg: IRelayDebugMessage) { } when (msg) { - is ErrorDebugMessage -> { - SelectionContainer { - Text( - text = msg.message, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - - is NoticeDebugMessage -> { + is IRelayDebugMessageText -> { + // ErrorDebugMessage and NoticeDebugMessage both carry plain-text bodies and + // render identically here; the label/colour above already distinguishes them. SelectionContainer { Text( text = msg.message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 1e5379347b..9f39abc8ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.IPubKeyEntity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -191,12 +192,9 @@ class SearchBarViewModel( .getOrNull() ?: return@mapLatest null when (parsed) { - is NPub -> { - LocalCache.consume(parsed) - Route.Profile(parsed.hex) - } - - is NProfile -> { + // Both NPub (npub1…) and NProfile (nprofile1…, npub + relay hints) + // resolve to the same profile route by hex pubkey. + is IPubKeyEntity -> { LocalCache.consume(parsed) Route.Profile(parsed.hex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 244bf222ff..5edbaf98b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -248,7 +248,6 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -766,18 +765,6 @@ private fun FullBleedNoteCompose( RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) } else if (noteEvent is GoalEvent) { RenderGoal(baseNote, accountViewModel, nav) - } else if (noteEvent is CommentEvent) { - RenderTextEvent( - baseNote, - false, - canPreview, - quotesLeft = 3, - unPackReply = ReplyRenderType.NONE, - backgroundColor, - editState, - accountViewModel, - nav, - ) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is RelayDiscoveryEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index f17b1bc3b2..04b4a75fd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -31,13 +31,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response @@ -567,17 +567,13 @@ class WalletViewModel : ViewModel() { fetchBalance() } - is PayInvoiceErrorResponse -> { + is IErrorResponseLike -> { + // Both PayInvoiceErrorResponse (method-specific, kept for + // back-compat) and NwcErrorResponse (generic) reduce to the + // same user-visible "payment failed" message. _sendState.value = SendState.Error( - response.error?.message ?: PAYMENT_FAILED, - ) - } - - is NwcErrorResponse -> { - _sendState.value = - SendState.Error( - response.error?.message ?: PAYMENT_FAILED, + response.errorMessage() ?: PAYMENT_FAILED, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index 64349dcaf0..f5b01d7016 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -90,6 +90,11 @@ sealed interface IRelayDebugMessage { val time: Long } +/** Debug messages that carry a single plain-text payload (notices, errors). */ +sealed interface IRelayDebugMessageText : IRelayDebugMessage { + val message: String +} + class SpamDebugMessage( val link1: String, val link2: String, @@ -97,11 +102,11 @@ class SpamDebugMessage( ) : IRelayDebugMessage class NoticeDebugMessage( - val message: String, + override val message: String, override val time: Long = TimeUtils.now(), -) : IRelayDebugMessage +) : IRelayDebugMessageText class ErrorDebugMessage( - val message: String, + override val message: String, override val time: Long = TimeUtils.now(), -) : IRelayDebugMessage +) : IRelayDebugMessageText diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/BaseRepostEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/BaseRepostEvent.kt new file mode 100644 index 0000000000..4bf1e19853 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/BaseRepostEvent.kt @@ -0,0 +1,30 @@ +/* + * 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.nip18Reposts + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +interface BaseRepostEvent { + fun boostedEventId(): HexKey? + + fun boostedAddress(): Address? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index 9ef37f5d8c..f4056e4d23 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -53,7 +53,8 @@ class GenericRepostEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, PubKeyHintProvider, - AddressHintProvider { + AddressHintProvider, + BaseRepostEvent { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) @@ -70,9 +71,9 @@ class GenericRepostEvent( fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) - fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + override fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) - fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + override fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index 39a6391a6d..c36f3ca851 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -53,7 +53,8 @@ class RepostEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, PubKeyHintProvider, - AddressHintProvider { + AddressHintProvider, + BaseRepostEvent { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) @@ -70,9 +71,9 @@ class RepostEvent( fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) - fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + override fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) - fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + override fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/IPubKeyEntity.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/IPubKeyEntity.kt new file mode 100644 index 0000000000..9f197be991 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/IPubKeyEntity.kt @@ -0,0 +1,32 @@ +/* + * 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.nip19Bech32.entities + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * NIP-19 bech32 entities that encode a single public key — NPub (npub1…) + * and NProfile (nprofile1…, npub + relay hints). Lets routing and lookup + * code resolve to a profile without branching on the concrete entity. + */ +interface IPubKeyEntity : Entity { + val hex: HexKey +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index 38e1206488..50aef064d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes @@ -34,9 +35,9 @@ import com.vitorpamplona.quartz.nip19Bech32.toNProfile @Immutable data class NProfile( - val hex: String, + override val hex: HexKey, val relay: List, -) : Entity { +) : IPubKeyEntity { companion object { fun parse(bytes: ByteArray): NProfile? { if (bytes.isEmpty()) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt index 24228dc91d..87c8b89c9e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub @Immutable data class NPub( - val hex: String, -) : Entity { + override val hex: HexKey, +) : IPubKeyEntity { companion object { fun parse(bytes: ByteArray): NPub? { if (bytes.isEmpty()) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 7f8669fda2..13110f3c74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -27,11 +27,23 @@ abstract class Response( val resultType: String, ) : OptimizedSerializable +/** + * NIP-47 responses that carry a human-readable error message. Lets UI + * collapse generic NwcErrorResponse and method-specific *ErrorResponse + * handling into one branch. + */ +interface IErrorResponseLike { + fun errorMessage(): String? +} + // Generic error response for any method class NwcErrorResponse( resultType: String, val error: NwcError? = null, -) : Response(resultType) +) : Response(resultType), + IErrorResponseLike { + override fun errorMessage() = error?.message +} // pay_invoice success response class PayInvoiceSuccessResponse( @@ -46,11 +58,14 @@ class PayInvoiceSuccessResponse( // pay_invoice error response (kept for backward compatibility) class PayInvoiceErrorResponse( val error: PayInvoiceErrorParams? = null, -) : Response(NwcMethod.PAY_INVOICE) { +) : Response(NwcMethod.PAY_INVOICE), + IErrorResponseLike { class PayInvoiceErrorParams( val code: NwcErrorCode? = null, val message: String? = null, ) + + override fun errorMessage() = error?.message } // pay_keysend success response diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveStreamLike.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveStreamLike.kt new file mode 100644 index 0000000000..190b71474b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveStreamLike.kt @@ -0,0 +1,47 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag + +/** + * Shared surface of the NIP-53 streaming + meeting-room events. Both + * LiveActivitiesEvent (kind 30311) and MeetingRoomEvent (kind 30312) + * expose the same title/summary/image/streaming/starts/status/participants + * tags so that callers — discovery feeds, cards, notifications — can + * render them uniformly without branching on the concrete subclass. + */ +interface LiveStreamLike { + fun title(): String? + + fun summary(): String? + + fun image(): String? + + fun streaming(): String? + + fun starts(): Long? + + fun status(): StatusTag.STATUS? + + fun participants(): List +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt index d01eff0d5f..5c5c34d921 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag @@ -64,7 +65,8 @@ class MeetingRoomEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + LiveStreamLike { override fun eventHints(): List { val pinnedEvents = pinned() if (pinnedEvents.isEmpty()) return emptyList() @@ -95,21 +97,21 @@ class MeetingRoomEvent( fun interactiveRoom() = tags.firstNotNullOfOrNull(MeetingSpaceTag::parse) - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + override fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + override fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + override fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) + override fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) fun recording() = tags.firstNotNullOfOrNull(RecordingTag::parse) - fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) + override fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) - fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + override fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) fun isLive() = status() == StatusTag.STATUS.LIVE @@ -119,7 +121,7 @@ class MeetingRoomEvent( fun participantKeys(): List = tags.mapNotNull(ParticipantTag::parseKey) - fun participants() = tags.mapNotNull(ParticipantTag::parse) + override fun participants() = tags.mapNotNull(ParticipantTag::parse) fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index 61418a5b32..5ca0e1d4a6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -60,7 +61,8 @@ class LiveActivitiesEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + LiveStreamLike { override fun eventHints(): List { val pinnedEvents = pinned() if (pinnedEvents.isEmpty()) return emptyList() @@ -85,21 +87,21 @@ class LiveActivitiesEvent( override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + override fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + override fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + override fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) + override fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) fun recording() = tags.firstNotNullOfOrNull(RecordingTag::parse) - fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) + override fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) - fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + override fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) fun isLive() = status() == StatusTag.STATUS.LIVE @@ -109,7 +111,7 @@ class LiveActivitiesEvent( fun participantKeys(): List = tags.mapNotNull(ParticipantTag::parseKey) - fun participants() = tags.mapNotNull(ParticipantTag::parse) + override fun participants() = tags.mapNotNull(ParticipantTag::parse) fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt new file mode 100644 index 0000000000..45b0b0af55 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt @@ -0,0 +1,32 @@ +/* + * 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.nip59Giftwrap + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Marker for NIP-59 wrappers that carry an inner-event id once decrypted — + * GiftWrapEvent (and its subclasses) and SealedRumorEvent. Lets callers walk + * to the wrapped event without branching on the concrete wrapper type. + */ +interface HasInnerEvent { + val innerEventId: HexKey? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index c95d868543..3591a5d7a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor @@ -39,10 +40,11 @@ class SealedRumorEvent( tags: Array>, content: String, sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + HasInnerEvent { @kotlinx.serialization.Transient @kotlin.jvm.Transient - var innerEventId: HexKey? = null + override var innerEventId: HexKey? = null fun copyNoContent(): SealedRumorEvent { val copy = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 093d1b974e..3b3fbfa371 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.utils.Log @@ -44,10 +45,11 @@ open class GiftWrapEvent( content: String, sig: HexKey, kind: Int = KIND, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), + HasInnerEvent { @kotlinx.serialization.Transient @kotlin.jvm.Transient - var innerEventId: HexKey? = null + override var innerEventId: HexKey? = null open fun copyNoContent(): GiftWrapEvent { val copy =