fix(sonar-s1871): match ChannelMessage/Metadata explicitly, not via IsInPublicChatChannel

fix(sonar-s1871): merge PayInvoice/Nwc error arms via IErrorResponseLike
fix(sonar-s1871): drop redundant is CommentEvent branch in ThreadFeedView
fix(sonar-s1871): merge NPub/NProfile route arms via IPubKeyEntity
fix(sonar-s1871): merge Error/Notice debug-message arms via IRelayDebugMessageText
fix(sonar-s1871): merge LiveActivities/MeetingRoom arms via LiveStreamLike
fix(sonar-s1871): merge channel-message/metadata arms via IsInPublicChatChannel
fix(sonar-s1871): collapse channel-draft reply handling via BaseThreadedEvent
fix(sonar-s1871): drop redundant is CommentEvent arm in NoteCompose
fix(sonar-s1871): merge Failed/Error arms in NIP-05 badge
fix(sonar-s1871): merge Verifying/NotStarted arms in NIP-05 badge
fix(sonar-s1871): merge note-backed RenderOption arms via NoteBackedName
fix(sonar-s1871): drop redundant is PrivateDmEvent arm in RouteMaker
fix(sonar-s1871): merge GiftWrap/SealedRumor arms in RouteMaker via HasInnerEvent
fix(sonar-s1871): merge Connecting/Connected arms in CallSession state collector
fix(sonar-s1871): collapse playback-state when to if/else in CurrentPlayPositionCacher
fix(sonar-s1871): drop redundant is CommentEvent arm in sendPublicReply
fix(sonar-s1871): merge addressable-filter arms via AddressableTopFilter
fix(sonar-s1871): merge repost branches in LocalCache via BaseRepostEvent
fix(sonar-s1871): merge badge-set branches in LocalCache referenced-notes when
This commit is contained in:
davotoula
2026-05-30 19:43:03 +02:00
parent ff4b4323d1
commit 70f72d4604
32 changed files with 322 additions and 287 deletions
@@ -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(
@@ -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) },
)
}
@@ -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,
@@ -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),
@@ -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)
}
}
}
@@ -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() }
@@ -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,
@@ -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)
}
@@ -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),
@@ -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,
@@ -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()
@@ -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)
@@ -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)
@@ -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(),
@@ -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,
@@ -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)
}
@@ -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) {
@@ -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,
)
}
@@ -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
@@ -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?
}
@@ -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)
@@ -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)
@@ -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
}
@@ -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<NormalizedRelayUrl>,
) : Entity {
) : IPubKeyEntity {
companion object {
fun parse(bytes: ByteArray): NProfile? {
if (bytes.isEmpty()) return null
@@ -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
@@ -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
@@ -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<ParticipantTag>
}
@@ -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<EventIdHint> {
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<HexKey> = tags.mapNotNull(ParticipantTag::parseKey)
fun participants() = tags.mapNotNull(ParticipantTag::parse)
override fun participants() = tags.mapNotNull(ParticipantTag::parse)
fun relays() = tags.mapNotNull(RelayListTag::parse).flatten()
@@ -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<EventIdHint> {
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<HexKey> = tags.mapNotNull(ParticipantTag::parseKey)
fun participants() = tags.mapNotNull(ParticipantTag::parse)
override fun participants() = tags.mapNotNull(ParticipantTag::parse)
fun relays() = tags.mapNotNull(RelayListTag::parse).flatten()
@@ -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?
}
@@ -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<Array<String>>,
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 =
@@ -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 =