Merge branch 'main' into claude/migrate-quartz-nip58-XQ8bw

This commit is contained in:
Vitor Pamplona
2026-03-31 09:17:11 -04:00
committed by GitHub
58 changed files with 1800 additions and 962 deletions
+3
View File
@@ -163,3 +163,6 @@ TASKS.md
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# Git worktrees
.worktrees/
@@ -580,84 +580,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: ExternalIdentitiesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ContactListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BookmarkListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: TextNoteEvent,
relay: NormalizedRelayUrl? = null,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PublicMessageEvent,
relay: NormalizedRelayUrl? = null,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: TorrentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: InteractiveStoryPrologueEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: InteractiveStorySceneEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: InteractiveStoryReadingStateEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorProficiencyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consumeRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
@@ -694,135 +616,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: PictureEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: VoiceEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: VoiceReplyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume(
event: TorrentCommentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90StatusEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90UserDiscoveryResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: RequestToVanishEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GoalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GitPatchEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GitIssueEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume(
event: GitReplyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
// Chess events (NIP-64 live chess)
fun consume(
event: ChessGameEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: JesterEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameChallengeEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameAcceptEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessMoveEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameEndEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessDrawOfferEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NipTextEvent,
relay: NormalizedRelayUrl?,
@@ -1103,12 +896,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: ZapPollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: LiveActivitiesEvent,
relay: NormalizedRelayUrl?,
@@ -1150,234 +937,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: LabeledBookmarkListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: MuteListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: CommunityListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: GitRepositoryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: RootSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NamedSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ChannelListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BlossomServersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: FileServersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: PeopleListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: EphemeralChatListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: FollowListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ChatMessageRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: HashtagListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: GeohashListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: SearchRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: BlockedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelayFeedsListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustProviderListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ProxyRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: IndexerRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: BroadcastRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CommunityDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: EmojiPackSelectionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: EmojiPackEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ClassifiedsEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: PinListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelaySetEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: AudioTrackEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoVerticalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoHorizontalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoNormalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: VideoShortEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: RelayDiscoveryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelayMonitorEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: StatusEvent,
relay: NormalizedRelayUrl?,
@@ -1438,66 +997,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: BadgeDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BadgeProfilesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BadgeAwardEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: NNSEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AppDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: WebBookmarkEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarDateSlotEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarTimeSlotEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarRSVPEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consumeBaseReplaceable(
event: Event,
relay: NormalizedRelayUrl?,
@@ -1543,24 +1042,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: AppRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AppSpecificDataEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: PrivateDmEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: DeletionEvent,
relay: NormalizedRelayUrl?,
@@ -1993,12 +1474,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
fun consume(
event: CommentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("UNUSED_PARAMETER")
fun consume(
event: ChannelHideMessageEvent,
@@ -2080,36 +1555,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: AudioHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FileHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: ProfileGalleryEntryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FileStorageHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FhirResourceEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: TextNoteModificationEvent,
relay: NormalizedRelayUrl?,
@@ -2145,30 +1590,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: HighlightEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: CodeSnippetEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: ChatEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollResponseEvent,
relay: NormalizedRelayUrl?,
@@ -2242,30 +1663,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
private fun consume(
event: ChatMessageEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: ChatMessageEncryptedFileHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: SealedRumorEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GiftWrapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LnZapPaymentRequestEvent,
relay: NormalizedRelayUrl?,
@@ -3141,134 +2538,134 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean =
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event, relay, wasVerified)
is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
is AttestationEvent -> consume(event, relay, wasVerified)
is AttestationRequestEvent -> consume(event, relay, wasVerified)
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
is AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified)
is BadgeDefinitionEvent -> consume(event, relay, wasVerified)
is BadgeProfilesEvent -> consume(event, relay, wasVerified)
is BlockedRelayListEvent -> consume(event, relay, wasVerified)
is BlossomServersEvent -> consume(event, relay, wasVerified)
is BroadcastRelayListEvent -> consume(event, relay, wasVerified)
is BookmarkListEvent -> consume(event, relay, wasVerified)
is CalendarEvent -> consume(event, relay, wasVerified)
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
is CalendarRSVPEvent -> consume(event, relay, wasVerified)
is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified)
is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BadgeProfilesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChannelCreateEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
is ChannelMessageEvent -> consume(event, relay, wasVerified)
is ChannelMetadataEvent -> consume(event, relay, wasVerified)
is ChannelMuteUserEvent -> consume(event, relay, wasVerified)
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified)
is ChatMessageEvent -> consume(event, relay, wasVerified)
is ChatMessageRelayListEvent -> consume(event, relay, wasVerified)
is ClassifiedsEvent -> consume(event, relay, wasVerified)
is CommentEvent -> consume(event, relay, wasVerified)
is CommunityDefinitionEvent -> consume(event, relay, wasVerified)
is CommunityListEvent -> consume(event, relay, wasVerified)
is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommentEvent -> consumeRegularEvent(event, relay, wasVerified)
is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified)
is ContactListEvent -> consume(event, relay, wasVerified)
is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DeletionEvent -> consume(event, relay, wasVerified)
is DraftWrapEvent -> consume(event, relay, wasVerified)
is EmojiPackEvent -> consume(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EphemeralChatEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consume(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GenericRepostEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consume(event, relay, wasVerified)
is FileHeaderEvent -> consume(event, relay, wasVerified)
is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified)
is FileServersEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified)
is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified)
is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is FileStorageEvent -> consume(event, relay, wasVerified)
is FileStorageHeaderEvent -> consume(event, relay, wasVerified)
is FollowListEvent -> consume(event, relay, wasVerified)
is GeohashListEvent -> consume(event, relay, wasVerified)
is GoalEvent -> consume(event, relay, wasVerified)
is GiftWrapEvent -> consume(event, relay, wasVerified)
is GitIssueEvent -> consume(event, relay, wasVerified)
is GitReplyEvent -> consume(event, relay, wasVerified)
is GitPatchEvent -> consume(event, relay, wasVerified)
is GitRepositoryEvent -> consume(event, relay, wasVerified)
is RootSiteEvent -> consume(event, relay, wasVerified)
is NamedSiteEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified)
is RelayFeedsListEvent -> consume(event, relay, wasVerified)
is JesterEvent -> consume(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified)
is HashtagListEvent -> consume(event, relay, wasVerified)
is HighlightEvent -> consume(event, relay, wasVerified)
is IndexerRelayListEvent -> consume(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
is LnZapEvent -> consume(event, relay, wasVerified)
is LnZapRequestEvent -> consume(event, relay, wasVerified)
is NIP90StatusEvent -> consume(event, relay, wasVerified)
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified)
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified)
is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified)
is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified)
is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified)
is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified)
is LongTextNoteEvent -> consume(event, relay, wasVerified)
is MetadataEvent -> consume(event, relay, wasVerified)
is MuteListEvent -> consume(event, relay, wasVerified)
is NNSEvent -> consume(event, relay, wasVerified)
is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NipTextEvent -> consume(event, relay, wasVerified)
is OtsEvent -> consume(event, relay, wasVerified)
is PictureEvent -> consume(event, relay, wasVerified)
is PrivateDmEvent -> consume(event, relay, wasVerified)
is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified)
is ProxyRelayListEvent -> consume(event, relay, wasVerified)
is PinListEvent -> consume(event, relay, wasVerified)
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is RequestToVanishEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is ZapPollEvent -> consume(event, relay, wasVerified)
is ChatEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PictureEvent -> consumeRegularEvent(event, relay, wasVerified)
is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified)
is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified)
is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified)
is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatEvent -> consumeRegularEvent(event, relay, wasVerified)
is PollEvent -> consumeRegularEvent(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
is RelayMonitorEvent -> consume(event, relay, wasVerified)
is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
is ContactCardEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ReportEvent -> consume(event, relay, wasVerified)
is RepostEvent -> consume(event, relay, wasVerified)
is SealedRumorEvent -> consume(event, relay, wasVerified)
is SearchRelayListEvent -> consume(event, relay, wasVerified)
is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified)
is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is StatusEvent -> consume(event, relay, wasVerified)
is TextNoteEvent -> consume(event, relay, wasVerified)
is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified)
is TextNoteModificationEvent -> consume(event, relay, wasVerified)
is TorrentEvent -> consume(event, relay, wasVerified)
is TorrentCommentEvent -> consume(event, relay, wasVerified)
is TrustedRelayListEvent -> consume(event, relay, wasVerified)
is TrustProviderListEvent -> consume(event, relay, wasVerified)
is VideoHorizontalEvent -> consume(event, relay, wasVerified)
is VideoNormalEvent -> consume(event, relay, wasVerified)
is VideoVerticalEvent -> consume(event, relay, wasVerified)
is VideoShortEvent -> consume(event, relay, wasVerified)
is VoiceEvent -> consume(event, relay, wasVerified)
is VoiceReplyEvent -> consume(event, relay, wasVerified)
is WebBookmarkEvent -> consume(event, relay, wasVerified)
is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified)
is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified)
is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified)
is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified)
is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified)
is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified)
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false }
@@ -29,7 +29,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
@@ -43,10 +45,14 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
@@ -104,10 +110,33 @@ class EventNotificationConsumer(
Log.d(TAG) { "Unwrapped consume ${innerEvent.javaClass.simpleName}" }
when (innerEvent) {
is PrivateDmEvent -> notify(innerEvent, account)
is LnZapEvent -> notify(innerEvent, account)
is ChatMessageEvent -> notify(innerEvent, account)
is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account)
is PrivateDmEvent -> {
notify(innerEvent, account)
}
is LnZapEvent -> {
notify(innerEvent, account)
}
is ChatMessageEvent -> {
notify(innerEvent, account)
}
is ChatMessageEncryptedFileHeaderEvent -> {
notify(innerEvent, account)
}
is ReactionEvent -> {
notify(innerEvent, account)
}
is LiveChessGameAcceptEvent -> {
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_challenge_accepted)
}
is LiveChessMoveEvent -> {
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn)
}
}
}
}
@@ -481,6 +510,111 @@ class EventNotificationConsumer(
}
}
private suspend fun notify(
event: ReactionEvent,
account: Account,
) {
Log.d(TAG, "New Reaction to Notify")
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
// don't notify for own reactions
if (event.pubKey == account.signer.pubKey) return
// only notify if the reaction is for the current user
if (!event.isTaggedUser(account.signer.pubKey)) return
val reactedPostId = event.originalPost().firstOrNull() ?: return
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
val author = LocalCache.getOrCreateUser(event.pubKey)
val user = author.toBestDisplayName()
val userPicture = author.profilePicture()
val reactionContent = event.content
val reactionSymbol =
when {
reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19"
reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E"
else -> reactionContent
}
val title = "$reactionSymbol $user"
val reactedContent =
reactedNote
?.event
?.content
?.split("\n")
?.firstOrNull() ?: ""
val content =
if (reactedContent.isNotBlank()) {
stringRes(
applicationContext,
R.string.app_notification_reactions_channel_message_for,
reactedContent,
)
} else {
stringRes(
applicationContext,
R.string.app_notification_reactions_channel_message,
user,
)
}
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
notificationManager()
.sendReactionNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
private suspend fun notifyChessEvent(
event: BaseChessEvent,
account: Account,
contentStringRes: Int,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
event.pubKey != account.signer.pubKey
) {
val author = LocalCache.getOrCreateUser(event.pubKey)
val user = author.toBestDisplayName()
val userPicture = author.profilePicture()
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
val content = stringRes(applicationContext, contentStringRes, user)
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
notificationManager()
.sendChessNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
}
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
@@ -45,8 +45,14 @@ import kotlinx.coroutines.withContext
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private var reactionChannel: NotificationChannel? = null
private var chessChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
@@ -56,6 +62,8 @@ object NotificationUtils {
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
private const val REACTION_SUMMARY_ID = 0x40000
private const val CHESS_SUMMARY_ID = 0x30000
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -99,6 +107,104 @@ object NotificationUtils {
return zapChannel!!
}
fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel {
if (reactionChannel != null) return reactionChannel!!
reactionChannel =
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_reactions_channel_id),
stringRes(applicationContext, R.string.app_notification_reactions_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_reactions_channel_description)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(reactionChannel!!)
return reactionChannel!!
}
fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel {
if (chessChannel != null) return chessChannel!!
chessChannel =
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_chess_channel_id),
stringRes(applicationContext, R.string.app_notification_chess_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_chess_channel_description)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(chessChannel!!)
return chessChannel!!
}
suspend fun NotificationManager.sendReactionNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
getOrCreateReactionChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = REACTION_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = REACTION_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary),
applicationContext = applicationContext,
)
}
suspend fun NotificationManager.sendChessNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
getOrCreateChessChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = CHESS_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = CHESS_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary),
applicationContext = applicationContext,
)
}
suspend fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
@@ -79,16 +79,24 @@ class RelayProxyClientConnector(
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
Log.d("ManageRelayServices", "Pausing Tor Activity")
try {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
Log.d("ManageRelayServices", "Pausing Tor Activity")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor dormant: ${e.message}" }
}
}
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
try {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor active: ${e.message}" }
}
}
// only calls this if the client is not active. Otherwise goes to the else below
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
@@ -33,6 +34,7 @@ val ReportsAndBookmarksFromKeyKinds =
listOf(
ReportEvent.KIND,
BookmarkListEvent.KIND,
LabeledBookmarkListEvent.KIND,
PinListEvent.KIND,
RequestToVanishEvent.KIND,
)
@@ -48,6 +48,8 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
@@ -87,6 +89,8 @@ val NotificationsPerKeyKinds2 =
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
)
val NotificationsPerKeyKinds3 =
@@ -26,8 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlin.coroutines.cancellation.CancellationException
class UrlUserTagOutputTransformation(
@@ -36,15 +35,20 @@ class UrlUserTagOutputTransformation(
override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString()
// Find all @npub mentions using regex and replace in reverse order
// Find all user mentions using regex and replace in reverse order
// so that earlier indices remain valid after replacements.
val npubRegex = Regex("@npub1[a-z0-9]{58}")
val matches = npubRegex.findAll(text).toList().reversed()
// Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1...
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
val matches = mentionRegex.findAll(text).toList().reversed()
for (match in matches) {
try {
val key = decodePublicKey(match.value.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val bech32 =
match.value
.removePrefix("@")
.removePrefix("nostr:")
val hex = decodePublicKeyAsHexOrNull(bech32) ?: continue
val user = LocalCache.getOrCreateUser(hex)
val displayName = "@${user.toBestDisplayName()}"
replace(match.range.first, match.range.last + 1, displayName)
@@ -31,8 +31,10 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(
@@ -60,20 +62,19 @@ fun buildAnnotatedStringWithUrlHighlighting(
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val mention = parseUserMention(word)
if (mention != null) {
val (keyPortion, restOfWord, hexKey) = mention
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyB32$restOfWord ",
"$keyPortion$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val endIndex = startIndex + keyPortion.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val user = LocalCache.getOrCreateUser(hexKey)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
@@ -181,3 +182,46 @@ fun buildAnnotatedStringWithUrlHighlighting(
numberOffsetTranslator,
)
}
private data class UserMention(
val keyPortion: String,
val restOfWord: String,
val hexKey: String,
)
private fun parseUserMention(word: String): UserMention? {
var key = word
val prefix: String
if (key.startsWith("nostr:", true)) {
prefix = key.substring(0, 6)
key = key.substring(6)
} else if (key.startsWith("@")) {
prefix = "@"
key = key.substring(1)
} else {
return null
}
if (key.startsWith("npub1", true) && key.length >= 63) {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
return UserMention("$prefix$keyB32", restOfWord, hex)
} else if (key.startsWith("nprofile1", true)) {
val parsed = Nip19Parser.uriToRoute(key) ?: return null
val entity = parsed.entity
if (entity !is NProfile && entity !is NPub) return null
val hex =
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
else -> return null
}
val bech32Len = parsed.nip19raw.length
val restOfWord = key.substring(bech32Len)
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
}
return null
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -244,7 +245,7 @@ open class CommentPostViewModel :
}
open fun quote(quote: Note) {
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -97,11 +98,12 @@ fun RenderLiveChessChallenge(
) {
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
val gameId = event.gameId()
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
val chessViewModel: ChessViewModelNew =
viewModel(
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
val isOpenChallenge = event.opponentPubkey() == null
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
/**
* Android implementation of ChessEventPublisher using Jester protocol.
@@ -59,9 +60,9 @@ class AndroidChessPublisher(
* Uses ChessEventBroadcaster to ensure relay connections before sending.
*/
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
val result = broadcaster.broadcast(event)
println("[AndroidChessPublisher] Broadcast result: ${result.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcast result: ${result.message}" }
return result.success
}
@@ -92,7 +93,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[AndroidChessPublisher] publishStart failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishStart failed: ${e.message}" }
null
}
@@ -118,7 +119,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[AndroidChessPublisher] publishMove failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishMove failed: ${e.message}" }
null
}
@@ -145,7 +146,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
success
} catch (e: Exception) {
println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishGameEnd failed: ${e.message}" }
false
}
@@ -180,7 +181,7 @@ class AndroidRelayFetcher(
}
override fun getRelayUrls(): List<String> {
println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
return ChessConfig.CHESS_RELAYS
}
@@ -193,7 +194,7 @@ class AndroidRelayFetcher(
* 2. Fetch moves that reference the start event via #e tag
*/
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId" }
// Filter 1: Fetch the start event by its ID
val startEventFilter =
@@ -223,7 +224,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}" }
return JesterGameEvents(
startEvent = startEvent,
@@ -237,30 +238,30 @@ class AndroidRelayFetcher(
*/
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
val relays = chessRelayUrls()
println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays" }
if (relays.isEmpty()) {
println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!" }
return emptyList()
}
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}")
println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}" }
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}" }
val relayFilters = relays.associateWith { listOf(filter) }
val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events" }
// Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events
if (events.isEmpty()) {
println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter..." }
val debugFilter = ChessFilterBuilder.recentGamesFilter()
val debugFilters = relays.associateWith { listOf(debugFilter) }
val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000)
println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events" }
debugEvents.take(5).forEach { e ->
println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}" }
}
}
@@ -286,7 +287,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events" }
return challenges
}
@@ -356,7 +357,7 @@ class AndroidRelayFetcher(
* Uses one-shot fetch for reliability.
*/
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: fetching from relays" }
// Fetch events authored by user AND events tagging user
val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
@@ -386,7 +387,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events" }
return gameIds
}
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.ui.stringRes
@@ -101,6 +102,7 @@ fun ChessGameScreen(
factory =
ChessViewModelFactory(
accountViewModel.account,
activity.application,
),
)
@@ -283,7 +285,10 @@ fun ChessGameScreen(
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.popBack() }) {
Button(onClick = {
chessViewModel.removeGame(gameId)
nav.popBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
@@ -295,12 +300,42 @@ fun ChessGameScreen(
}
else -> {
// Resolve opponent display name
// Resolve opponent display name and avatar
val opponentUser =
remember(gameState.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)
}
val opponentDisplayName =
remember(gameState.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName()
?: gameState.opponentPubkey.take(8)
opponentUser?.toBestDisplayName() ?: gameState.opponentPubkey.take(8)
}
val opponentAvatarUrl =
remember(gameState.opponentPubkey) {
opponentUser?.profilePicture()
}
// Resolve player display name and avatar
val playerUser =
remember(gameState.playerPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.playerPubkey)
}
val playerDisplayName =
remember(gameState.playerPubkey) {
playerUser?.toBestDisplayName() ?: gameState.playerPubkey.take(8)
}
val playerAvatarUrl =
remember(gameState.playerPubkey) {
playerUser?.profilePicture()
}
// Determine white/black pubkeys, names and avatars based on player color
val isPlayerWhite = gameState.playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE
val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey
val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey
val whiteDisplayName = if (isPlayerWhite) playerDisplayName else opponentDisplayName
val blackDisplayName = if (isPlayerWhite) opponentDisplayName else playerDisplayName
val whiteAvatarUrl = if (isPlayerWhite) playerAvatarUrl else opponentAvatarUrl
val blackAvatarUrl = if (isPlayerWhite) opponentAvatarUrl else playerAvatarUrl
// Determine spectator status:
// 1. If game was accepted locally, user is definitely NOT a spectator
@@ -319,6 +354,23 @@ fun ChessGameScreen(
},
onResign = { chessViewModel.resign(gameId) },
isSpectatorOverride = isSpectating,
onGameEndDismiss = { chessViewModel.dismissGame(gameId) },
onLeaveSpectating =
if (isSpectating) {
{
chessViewModel.stopSpectating(gameId)
nav.popBack()
}
} else {
null
},
whiteName = whiteDisplayName,
whiteHex = whitePubkey,
whiteAvatarUrl = whiteAvatarUrl,
blackName = blackDisplayName,
blackHex = blackPubkey,
blackAvatarUrl = blackAvatarUrl,
onPlayerClick = { pubkeyHex -> nav.nav(Route.Profile(pubkeyHex)) },
)
}
}
@@ -45,6 +45,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -67,8 +68,10 @@ import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard
import com.vitorpamplona.amethyst.commons.chess.ChallengeCard
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard
import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars
import com.vitorpamplona.amethyst.commons.chess.PublicGameCard
import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -94,7 +97,7 @@ fun ChessLobbyScreen(
viewModel(
viewModelStoreOwner = activity,
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
// Subscribe to chess events when screen is visible
@@ -294,13 +297,19 @@ fun ChessLobbyContent(
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val publicGames by chessViewModel.publicGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState()
val completedGames by chessViewModel.completedGames.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
val currentUser =
remember(userPubkey) {
accountViewModel.checkGetOrCreateUser(userPubkey)
}
val hasContent =
activeGames.isNotEmpty() ||
spectatingGames.isNotEmpty() ||
publicGames.isNotEmpty() ||
challenges.isNotEmpty()
challenges.isNotEmpty() ||
completedGames.isNotEmpty()
if (!hasContent) {
// Empty state - use LazyColumn so pull-to-refresh works
@@ -359,15 +368,24 @@ fun ChessLobbyContent(
}
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
val displayName =
val opponent =
remember(state.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8)
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)
}
val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8)
ActiveGameCard(
gameId = gameId,
opponentName = displayName,
isYourTurn = state.isPlayerTurn(),
onClick = { onSelectGame(gameId) },
avatar = {
OverlappingAvatars(
avatar1Hex = state.playerPubkey,
avatar2Hex = state.opponentPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponent?.profilePicture(),
)
},
)
}
}
@@ -386,14 +404,28 @@ fun ChessLobbyContent(
}
items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge ->
val opponentName =
challenge.opponentPubkey?.let { pubkey ->
accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8)
val opponentUser =
remember(challenge.opponentPubkey) {
challenge.opponentPubkey?.let { accountViewModel.checkGetOrCreateUser(it) }
}
val opponentName =
opponentUser?.toBestDisplayName()
?: challenge.opponentPubkey?.take(8)
OutgoingChallengeCard(
opponentName = opponentName,
userPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
onClick = { onOpenOwnChallenge(challenge) },
avatar =
challenge.opponentPubkey?.let { opPubkey ->
{
OverlappingAvatars(
avatar1Hex = userPubkey,
avatar2Hex = opPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponentUser?.profilePicture(),
)
}
},
)
}
}
@@ -447,6 +479,14 @@ fun ChessLobbyContent(
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = true,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl,
avatar2Url = currentUser?.profilePicture(),
)
},
)
}
}
@@ -476,6 +516,14 @@ fun ChessLobbyContent(
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = false,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl,
avatar2Url = currentUser?.profilePicture(),
)
},
)
}
}
@@ -504,6 +552,63 @@ fun ChessLobbyContent(
}
}
// Finished games section
if (completedGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Finished Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (completedGames.size >= 2) {
TextButton(onClick = { chessViewModel.dismissAllCompletedGames() }) {
Text("Clear all")
}
}
}
}
items(
completedGames.take(10),
key = { "completed-${it.gameId}-${it.completedAt}" },
) { game ->
val opponentPubkey =
if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey
val opponentUser =
remember(opponentPubkey) {
accountViewModel.checkGetOrCreateUser(opponentPubkey)
}
val opponentName =
opponentUser?.toBestDisplayName()
?: game.blackDisplayName
?: game.whiteDisplayName
?: opponentPubkey.take(8)
CompletedGameCard(
opponentName = opponentName,
result = game.result,
didUserWin = game.didUserWin(userPubkey),
isDraw = game.isDraw,
moveCount = game.moveCount,
onClick = { onSelectGame(game.gameId) },
onDismiss = { chessViewModel.dismissCompletedGame(game.gameId) },
avatar = {
OverlappingAvatars(
avatar1Hex = userPubkey,
avatar2Hex = opponentPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponentUser?.profilePicture(),
)
},
)
}
}
// Bottom padding for FAB
item {
Spacer(Modifier.height(80.dp))
@@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.model.Account
*/
class ChessViewModelFactory(
private val account: Account,
private val application: android.app.Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) {
return ChessViewModelNew(account) as T
return ChessViewModelNew(account, application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.StateFlow
/**
@@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
@Stable
class ChessViewModelNew(
private val account: Account,
application: android.app.Application,
) : ViewModel() {
// Instance ID for debugging ViewModel sharing
val instanceId = System.identityHashCode(this)
@@ -58,6 +61,7 @@ class ChessViewModelNew(
private val publisher = AndroidChessPublisher(account)
private val fetcher = AndroidRelayFetcher(account)
private val metadataProvider = AndroidMetadataProvider()
private val dismissedStorage = ChessDismissedGamesStorage.create(application)
// Shared business logic (creates its own ChessLobbyState internally)
private val logic =
@@ -68,6 +72,7 @@ class ChessViewModelNew(
metadataProvider = metadataProvider,
scope = viewModelScope,
pollingConfig = ChessPollingDefaults.android,
dismissedStorage = dismissedStorage,
)
// ============================================
@@ -94,6 +99,7 @@ class ChessViewModelNew(
// ============================================
init {
Log.d("chessdebug") { "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}" }
logic.startPolling()
}
@@ -103,6 +109,10 @@ class ChessViewModelNew(
fun forceRefresh() = logic.forceRefresh()
fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId)
fun dismissAllCompletedGames() = logic.dismissAllCompletedGames()
/**
* Ensure a game ID is being polled for updates.
* Call this when entering a game screen.
@@ -132,7 +142,12 @@ class ChessViewModelNew(
fun handleIncomingEvent(event: Event) {
if (event.kind != JesterProtocol.KIND) return
val jesterEvent = event.toJesterEvent() ?: return
val jesterEvent =
event.toJesterEvent() ?: run {
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" }
return
}
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" }
logic.handleIncomingEvent(jesterEvent)
}
@@ -166,6 +181,8 @@ class ChessViewModelNew(
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
fun dismissGame(gameId: String) = logic.dismissGame(gameId)
// ============================================
// Spectator operations
// ============================================
@@ -174,7 +191,9 @@ class ChessViewModelNew(
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
fun removeGame(gameId: String) = logic.removeGame(gameId)
// ============================================
// Utility
@@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Query state for chess subscription
@@ -39,16 +44,43 @@ data class ChessQueryState(
)
/**
* Sub-assembler that creates the actual relay filters
* Sub-assembler that creates the actual relay filters.
* Intercepts incoming chess events from relays and forwards them to the ViewModel.
*/
class ChessFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChessQueryState>,
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
var onChessEvent: ((Event) -> Unit)? = null
override fun updateFilter(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterChessEvents(key, since)
override fun id(key: ChessQueryState): String = key.userPubkey
override fun newSub(key: ChessQueryState): Subscription =
requestNewSubscription(
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
onChessEvent?.invoke(event)
}
},
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
@@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class ChessFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChessQueryState>() {
val group =
listOf(
ChessFeedFilterSubAssembler(client, ::allKeys),
)
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
val group = listOf(subAssembler)
fun setOnChessEvent(callback: ((Event) -> Unit)?) {
subAssembler.onChessEvent = callback
}
override fun invalidateKeys() = invalidateFilters()
@@ -66,8 +66,19 @@ fun ChessSubscription(
)
}
// Wire incoming chess events from relay subscriptions to the ViewModel
val chessAssembler = accountViewModel.dataSources().chess
DisposableEffect(chessAssembler, chessViewModel) {
chessAssembler.setOnChessEvent { event ->
chessViewModel.handleIncomingEvent(event)
}
onDispose {
chessAssembler.setOnChessEvent(null)
}
}
// Register subscription with Amethyst's subscription system
KeyDataSourceSubscription(state, accountViewModel.dataSources().chess)
KeyDataSourceSubscription(state, chessAssembler)
// Trigger ViewModel refresh when subscription state changes
// This fetches challenges from LocalCache after events arrive
@@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter(
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.LocalActivity
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
@@ -47,11 +48,12 @@ fun NewChessGameButton(
nav: INav,
) {
var showDialog by remember { mutableStateOf(false) }
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
val chessViewModel: ChessViewModelNew =
viewModel(
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
FloatingActionButton(
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -369,7 +370,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null
quote?.let { quotedNote ->
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
quotedNote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != user.pubkeyHex) {
@@ -262,8 +262,7 @@ class CardFeedContentState(
val sortedList =
singleList
.get(string)
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed()
?.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
sortedList?.chunked(30)?.map { chunk ->
MultiSetCard(
@@ -293,8 +292,7 @@ class CardFeedContentState(
ZapUserSetCard(
user.key,
zaps
.sortedWith(compareBy({ it.createdAt() }, { it.idHex() }))
.reversed()
.sortedWith(compareByDescending<CombinedZap> { it.createdAt() }.thenBy { it.idHex() })
.toImmutableList(),
)
}
@@ -318,8 +316,7 @@ class CardFeedContentState(
}
return (multiCards + textNoteCards + userZaps)
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
}
private fun updateFeed(notes: ImmutableList<Card>) {
@@ -383,8 +380,7 @@ class CardFeedContentState(
val updatedCards =
(oldNotesState.feed.value.list + newCards)
.distinctBy { it.id() }
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
.take(localFilter.limit())
.toImmutableList()
@@ -80,6 +80,6 @@ class OpenPollsState(
false
}
}
}.sortedByDescending { it.createdAt() }
}.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
}
}
@@ -57,6 +57,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -83,6 +85,8 @@ class NotificationFeedFilter(
CalendarRSVPEvent.KIND,
ClassifiedsEvent.KIND,
LiveActivitiesEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
LongTextNoteEvent.KIND,
NipTextEvent.KIND,
VideoVerticalEvent.KIND,
@@ -185,9 +189,12 @@ class NotificationFeedFilter(
}
}
// Chess events bypass the follow filter — opponents may not be followed
val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(isChessEvent || filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
@@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() {
sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed",
)
LocalCache.consume(releaseNotes, null, true)
LocalCache.consumeRegularEvent(releaseNotes, null, true)
}
val accountViewModel = mockAccountViewModel()
@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.ui.tor
import android.content.Context
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
@@ -71,6 +73,9 @@ class TorManager(
}
}
}
}.catch { e ->
Log.e("TorManager") { "Tor service error: ${e.message}" }
emit(TorServiceStatus.Off)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
@@ -54,18 +54,23 @@ class TorService(
service: IBinder,
) {
launch(Dispatchers.IO) {
// moved torService to a local variable, since we only need it once
val torService = (service as LocalBinder).service
try {
// moved torService to a local variable, since we only need it once
val torService = (service as LocalBinder).service
while (torService.socksPort < 0) {
delay(SOCKS_PORT_POLL_INTERVAL_MS)
while (torService.socksPort < 0) {
delay(SOCKS_PORT_POLL_INTERVAL_MS)
}
val active = TorServiceStatus.Active(torService.socksPort)
active.torControlConnection = torService.torControlConnection
trySend(active)
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
} catch (e: Exception) {
Log.e("TorService") { "Tor service connection failed: ${e.message}" }
trySend(TorServiceStatus.Off)
}
val active = TorServiceStatus.Active(torService.socksPort)
active.torControlConnection = torService.torControlConnection
trySend(active)
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
}
}
@@ -75,11 +80,16 @@ class TorService(
}
}
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
try {
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
} catch (e: Exception) {
Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" }
trySend(TorServiceStatus.Off)
}
awaitClose {
Log.d("TorService", "Stopping Tor Service")
@@ -88,8 +98,10 @@ class TorService(
} catch (e: Exception) {
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
}
launch {
try {
context.stopService(currentIntent)
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
}
trySend(TorServiceStatus.Off)
}
@@ -1622,6 +1622,7 @@
<string name="namecoin_test_connection">Test połączenia</string>
<string name="namecoin_testing">Testowanie serwerów…</string>
<string name="namecoin_test_success">Podłączony</string>
<string name="namecoin_test_failed">Test nieudany</string>
<string name="namecoin_test_results">Wyniki testu</string>
<string name="namecoin_diagnostics">Diagnostyka</string>
<string name="namecoin_last_test">Ostatni test</string>
@@ -655,6 +655,11 @@
<string name="app_notification_mark_read_label">标记为已读</string>
<string name="app_notification_dms_summary">新信息</string>
<string name="app_notification_zaps_summary">新打闪</string>
<string name="app_notification_chess_channel_name">国际象棋</string>
<string name="app_notification_chess_channel_description">提醒您有关国际象棋游戏的事件</string>
<string name="app_notification_chess_challenge_accepted">%1$s 接受了您的国际象棋挑战</string>
<string name="app_notification_chess_your_turn">%1$s 已移动 —— 轮到您了</string>
<string name="app_notification_chess_summary">国际象棋更新</string>
<string name="reply_notify">通知:</string>
<string name="channel_list_join_conversation">加入对话</string>
<string name="channel_list_user_or_group_id">用户或群组 ID</string>
+13
View File
@@ -751,6 +751,19 @@
<string name="app_notification_dms_summary">New messages</string>
<string name="app_notification_zaps_summary">New zaps</string>
<string name="app_notification_reactions_channel_id" translatable="false">ReactionsID</string>
<string name="app_notification_reactions_channel_name">Reactions</string>
<string name="app_notification_reactions_channel_description">Notifies you when somebody reacts to your post</string>
<string name="app_notification_reactions_channel_message">%1$s reacted to your post</string>
<string name="app_notification_reactions_channel_message_for">for %1$s</string>
<string name="app_notification_reactions_summary">New reactions</string>
<string name="app_notification_chess_channel_id" translatable="false">ChessID</string>
<string name="app_notification_chess_channel_name">Chess</string>
<string name="app_notification_chess_channel_description">Notifies you about chess game events</string>
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
<string name="app_notification_chess_summary">Chess updates</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import android.content.Context
import android.content.SharedPreferences
actual class ChessDismissedGamesStorage private actual constructor() {
private var prefs: SharedPreferences? = null
actual companion object {
private const val PREFS_NAME = "chess_dismissed_games"
private fun prefsKey(userPubkey: String) = "dismissed_$userPubkey"
actual fun create(context: Any?): ChessDismissedGamesStorage {
val storage = ChessDismissedGamesStorage()
val ctx =
context as? Context
?: throw IllegalArgumentException("Android context required")
storage.prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return storage
}
}
// getStringSet returns a live reference to the internal set — must copy defensively
actual fun load(userPubkey: String): Set<String> = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet()
actual fun save(
userPubkey: String,
ids: Set<String>,
) {
prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply()
}
}
@@ -79,6 +79,12 @@ fun ChessBoard(
} else {
null
},
rankLabel =
if (showCoordinates && file == 0) {
(rank + 1).toString()
} else {
null
},
)
}
}
@@ -95,6 +101,7 @@ private fun ChessSquare(
isLight: Boolean,
size: Dp,
coordinate: String?,
rankLabel: String? = null,
) {
Box(
modifier =
@@ -114,6 +121,20 @@ private fun ChessSquare(
)
}
// Show rank label (1-8) on left file
rankLabel?.let {
Text(
text = it,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5),
modifier =
Modifier
.align(Alignment.TopStart)
.padding(2.dp),
)
}
// Show file coordinate (a-h) on bottom rank
coordinate?.let {
Text(
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Persists dismissed chess game IDs locally per user.
* Uses expect/actual for platform-specific storage.
*/
expect class ChessDismissedGamesStorage private constructor() {
companion object {
fun create(context: Any? = null): ChessDismissedGamesStorage
}
fun load(userPubkey: String): Set<String>
fun save(
userPubkey: String,
ids: Set<String>,
)
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -88,14 +89,20 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (processedEventIds.contains(event.id)) {
Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" }
return false
}
// Check if this event belongs to our game
val eventStartId = event.startEventId()
val isStartEvent = event.isStartEvent() && event.id == startEventId
val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId
if (!isStartEvent && !isMoveEvent) return false
if (!isStartEvent && !isMoveEvent) {
Log.d("chessdebug") { "[Collector] REJECTED: event ${event.id.take(8)} not for game ${startEventId.take(8)} (isStart=$isStartEvent, isMove=$isMoveEvent, eventStartId=${eventStartId?.take(8)})" }
return false
}
return if (isStartEvent) {
addStartEvent(event)
@@ -129,6 +136,7 @@ class ChessEventCollector(
if (_startEvent.compareAndSet(null, event)) {
processedEventIds.add(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
return true
}
return false
@@ -148,6 +156,7 @@ class ChessEventCollector(
if (moves.putIfAbsent(event.id, event) == null) {
processedEventIds.add(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" }
return true
}
return false
@@ -254,13 +263,22 @@ class ChessEventCollectorManager {
fun addEvent(event: JesterEvent): Boolean {
// For start events, create collector with event ID
if (event.isStartEvent()) {
Log.d("chessdebug") { "[CollectorMgr] Routing START event ${event.id.take(8)} from ${event.pubKey.take(8)}" }
val collector = getOrCreate(event.id)
return collector.addEvent(event)
}
// For move events, find the collector by startEventId
val startId = event.startEventId() ?: return false
val collector = collectors[startId] ?: return false
val startId =
event.startEventId() ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: move event ${event.id.take(8)} has no startEventId" }
return false
}
val collector =
collectors[startId] ?: run {
Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" }
return false
}
return collector.addEvent(event)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -160,6 +161,10 @@ class ChessPollingDelegate(
*/
fun removeGameId(gameId: String) {
activeGameIdsFlow.value = activeGameIdsFlow.value - gameId
// If this was the focused game, clear focus to stop re-polling a finished game
if (_focusedGameId.value == gameId) {
_focusedGameId.value = null
}
}
/**
@@ -167,8 +172,10 @@ class ChessPollingDelegate(
*/
fun start() {
if (_isPolling.value) {
Log.d("chessdebug") { "[Polling] start: already polling, skipping" }
return
}
Log.d("chessdebug") { "[Polling] start: gameInterval=${config.activeGamePollInterval}ms, challengeInterval=${config.challengePollInterval}ms" }
_isPolling.value = true
// Poll for active games
@@ -177,10 +184,11 @@ class ChessPollingDelegate(
while (isActive) {
val gameIds = getEffectiveGameIds()
if (gameIds.isNotEmpty()) {
Log.d("chessdebug") { "[Polling] polling ${gameIds.size} games: ${gameIds.map { it.take(8) }}, focused=${_focusedGameId.value?.take(8)}" }
try {
onRefreshGames(gameIds)
} catch (_: Exception) {
// Error during refresh - continue polling
} catch (e: Exception) {
Log.d("chessdebug") { "[Polling] ERROR during game refresh: ${e.message}" }
}
}
delay(config.activeGamePollInterval)
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState
import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult
import com.vitorpamplona.quartz.nip64Chess.ViewerRole
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
/**
* Converts a ReconstructedGameState (from ChessStateReconstructor) into a
@@ -63,7 +64,10 @@ object ChessGameLoader {
result: ReconstructionResult,
viewerPubkey: String,
): LiveChessGameState? {
if (result !is ReconstructionResult.Success) return null
if (result !is ReconstructionResult.Success) {
Log.d("chessdebug") { "[GameLoader] toLiveGameState: reconstruction was not successful" }
return null
}
val state = result.state
val engine = result.engine
@@ -75,6 +79,8 @@ object ChessGameLoader {
ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "")
}
Log.d("chessdebug") { "[GameLoader] toLiveGameState: game=${state.startEventId.take(8)}, role=${state.viewerRole}, color=${state.playerColor}, isSpectator=${state.viewerRole == ViewerRole.SPECTATOR}, isPending=${state.isPendingChallenge}, moves=${state.moveHistory.size}" }
return LiveChessGameState(
startEventId = state.startEventId,
playerPubkey = playerPubkey,
@@ -91,9 +97,9 @@ object ChessGameLoader {
// Mark finished if the game has ended
if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) {
gameState.markAsFinished(
(state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result,
)
val gameResult = (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result
Log.d("chessdebug") { "[GameLoader] Marking game ${state.startEventId.take(8)} as finished: $gameResult" }
gameState.markAsFinished(gameResult)
}
// Handle pending draw offer
@@ -115,19 +121,23 @@ object ChessGameLoader {
events: JesterGameEvents,
viewerPubkey: String,
): LoadGameResult {
Log.d("chessdebug") { "[GameLoader] loadGame: startEvent=${events.startEvent?.id?.take(8)}, moves=${events.moves.size}, viewer=${viewerPubkey.take(8)}" }
val result = ChessStateReconstructor.reconstruct(events, viewerPubkey)
return when (result) {
is ReconstructionResult.Success -> {
val liveState = toLiveGameState(result, viewerPubkey)
if (liveState != null) {
Log.d("chessdebug") { "[GameLoader] loadGame SUCCESS: game=${result.state.startEventId.take(8)}, status=${result.state.gameStatus}" }
LoadGameResult.Success(liveState, result.state)
} else {
Log.d("chessdebug") { "[GameLoader] loadGame FAILED: could not convert to live state" }
LoadGameResult.Error("Failed to convert reconstructed state to live state")
}
}
is ReconstructionResult.Error -> {
Log.d("chessdebug") { "[GameLoader] loadGame ERROR: ${result.message}" }
LoadGameResult.Error(result.message)
}
}
@@ -149,6 +159,7 @@ object ChessGameLoader {
playerColor: Color,
isPendingChallenge: Boolean = false,
): LiveChessGameState {
Log.d("chessdebug") { "[GameLoader] createNewGame: game=${startEventId.take(8)}, player=${playerPubkey.take(8)}, opponent=${opponentPubkey.take(8)}, color=$playerColor, isPending=$isPendingChallenge" }
val engine = ChessEngine()
engine.reset()
@@ -27,8 +27,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -288,6 +292,8 @@ fun CompletedGameCard(
didUserWin: Boolean,
isDraw: Boolean,
moveCount: Int,
onClick: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
@@ -309,6 +315,8 @@ fun CompletedGameCard(
}
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = modifier.fillMaxWidth(),
) {
Row(
@@ -337,6 +345,15 @@ fun CompletedGameCard(
color = resultColor,
fontWeight = FontWeight.Bold,
)
if (onDismiss != null) {
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameStatus
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -122,9 +125,24 @@ class ChessLobbyLogic(
private val metadataProvider: IUserMetadataProvider,
private val scope: CoroutineScope,
pollingConfig: ChessPollingConfig = ChessPollingDefaults.android,
private val dismissedStorage: ChessDismissedGamesStorage? = null,
) {
val state = ChessLobbyState(userPubkey, scope)
private val dismissedGameIds: MutableSet<String> =
java.util.Collections.synchronizedSet(
dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
)
// Track when games were last loaded to prevent duplicate fetches
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
// Dedup incoming events (same event delivered by multiple relays)
// Bounded LRU: evict oldest when exceeding capacity
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
private val seenEventIdsMax = 500
private val pollingDelegate =
ChessPollingDelegate(
config = pollingConfig,
@@ -185,6 +203,21 @@ class ChessLobbyLogic(
* Called by platform subscription callbacks for real-time updates.
*/
fun handleIncomingEvent(event: JesterEvent) {
// Skip non-chess events (kind 30 but not start or move)
if (!event.isStartEvent() && !event.isMoveEvent()) return
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
synchronized(seenEventIds) {
if (!seenEventIds.add(event.id)) return
if (seenEventIds.size > seenEventIdsMax) {
seenEventIds.iterator().let {
it.next()
it.remove()
}
}
}
Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" }
when {
event.isStartEvent() -> handleStartEvent(event)
event.isMoveEvent() -> handleMoveEvent(event)
@@ -195,6 +228,8 @@ class ChessLobbyLogic(
val startEventId = event.id
val challengerColor = event.playerColor() ?: Color.WHITE
Log.d("chessdebug") { "[Lobby] handleStartEvent: game=${startEventId.take(8)}, challenger=${event.pubKey.take(8)}, color=$challengerColor, opponent=${event.opponentPubkey()?.take(8)}" }
val challenge =
ChessChallenge(
eventId = event.id,
@@ -210,12 +245,26 @@ class ChessLobbyLogic(
}
private fun handleMoveEvent(event: JesterEvent) {
val startEventId = event.startEventId() ?: return
val san = event.move() ?: return
val fen = event.fen() ?: return
val startEventId =
event.startEventId() ?: run {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no startEventId for event ${event.id.take(8)}" }
return
}
val san =
event.move() ?: run {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no move in event ${event.id.take(8)}" }
return
}
val fen =
event.fen() ?: run {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no FEN in event ${event.id.take(8)}" }
return
}
val history = event.history()
val moveNumber = history.size
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game=${startEventId.take(8)}, move=$san, moveNumber=$moveNumber, from=${event.pubKey.take(8)}, result=${event.result()}" }
// Check if this is our game (we're either the author or tagged as opponent)
val opponentFromTag = event.opponentPubkey()
val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey
@@ -225,23 +274,28 @@ class ChessLobbyLogic(
// If this is our game but we haven't loaded it yet, load it now
// This happens when someone accepts our challenge (makes first move)
if (gameState == null && isOurGame) {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded but is ours - calling handleGameAccepted" }
handleGameAccepted(startEventId)
return // handleGameAccepted will load the game and poll for events
}
if (gameState == null) return
if (gameState == null) {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded and not ours - ignoring" }
return
}
// Check for game end
val result = event.result()
if (result != null) {
val gameResult =
when (result) {
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
"1-0" -> GameResult.WHITE_WINS
"0-1" -> GameResult.BLACK_WINS
"1/2-1/2" -> GameResult.DRAW
else -> null
}
if (gameResult != null) {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} ENDED: $result, termination=${event.termination()}" }
gameState.markAsFinished(gameResult)
state.moveToCompleted(startEventId, result, event.termination())
pollingDelegate.removeGameId(startEventId)
@@ -251,9 +305,12 @@ class ChessLobbyLogic(
// Only apply opponent moves optimistically
if (event.pubKey != userPubkey) {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: applying opponent move $san (move #$moveNumber) to game ${startEventId.take(8)}" }
gameState.applyOpponentMove(san, fen, moveNumber)
// Update head event ID for move linking
gameState.updateHeadEventId(event.id)
} else {
Log.d("chessdebug") { "[Lobby] handleMoveEvent: skipping own move $san for game ${startEventId.take(8)}" }
}
}
@@ -266,6 +323,7 @@ class ChessLobbyLogic(
playerColor: Color = Color.WHITE,
timeControl: String? = null, // Not supported in Jester, kept for API compatibility
) {
Log.d("chessdebug") { "[Lobby] createChallenge: opponent=${opponentPubkey?.take(8)}, color=$playerColor" }
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
@@ -278,6 +336,7 @@ class ChessLobbyLogic(
val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) }
if (startEventId != null) {
Log.d("chessdebug") { "[Lobby] createChallenge SUCCESS: startEventId=${startEventId.take(8)}" }
// Add challenge to local state - shows in "Your Challenges" section
val challenge =
ChessChallenge(
@@ -300,6 +359,7 @@ class ChessLobbyLogic(
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
} else {
Log.d("chessdebug") { "[Lobby] createChallenge FAILED: publish returned null" }
state.setBroadcastStatus(
ChessBroadcastStatus.Failed("Challenge", "Failed to publish"),
)
@@ -315,6 +375,7 @@ class ChessLobbyLogic(
* In Jester protocol, acceptance is implicit - we just track the game locally.
*/
fun acceptChallenge(challenge: ChessChallenge) {
Log.d("chessdebug") { "[Lobby] acceptChallenge: game=${challenge.gameId.take(8)}, challenger=${challenge.challengerPubkey.take(8)}, color=${challenge.challengerColor.opposite()}" }
// Mark as accepted SYNCHRONOUSLY before launching coroutine
// This prevents race where navigation happens before coroutine runs,
// which would cause loadGame() to incorrectly mark as spectator
@@ -376,14 +437,27 @@ class ChessLobbyLogic(
* When we detect our challenge was accepted (opponent made first move), load game from relays.
*/
fun handleGameAccepted(startEventId: String) {
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
val lastLoaded = recentlyLoadedGames[startEventId]
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent launches
recentlyLoadedGames[startEventId] = TimeUtils.now()
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(startEventId)
Log.d("chessdebug") { "[Lobby] handleGameAccepted: fetched ${events.moves.size} moves for game ${startEventId.take(8)}" }
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
@@ -391,6 +465,7 @@ class ChessLobbyLogic(
}
is LoadGameResult.Error -> {
Log.d("chessdebug") { "[Lobby] handleGameAccepted FAILED: game ${startEventId.take(8)}, error=${result.message}" }
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
@@ -407,9 +482,15 @@ class ChessLobbyLogic(
from: String,
to: String,
) {
val gameState = state.getGameState(startEventId) ?: return
Log.d("chessdebug") { "[Lobby] publishMove: game=${startEventId.take(8)}, from=$from, to=$to" }
val gameState =
state.getGameState(startEventId) ?: run {
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - no game state for ${startEventId.take(8)}" }
return
}
if (state.isSpectating(startEventId)) {
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - spectating game ${startEventId.take(8)}" }
state.setError("Cannot move while spectating")
return
}
@@ -417,7 +498,13 @@ class ChessLobbyLogic(
// Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN)
val (actualTo, promotion) = parsePromotionFromTo(to)
val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return
val moveResult =
gameState.makeMove(from, actualTo, promotion) ?: run {
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - makeMove returned null for $from->$actualTo in game ${startEventId.take(8)}" }
return
}
Log.d("chessdebug") { "[Lobby] publishMove: move validated: san=${moveResult.san}, fen=${moveResult.fen.take(30)}, history=${moveResult.history.size} moves, headEvent=${moveResult.headEventId.take(8)}" }
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
@@ -431,6 +518,7 @@ class ChessLobbyLogic(
val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) }
if (newEventId != null) {
Log.d("chessdebug") { "[Lobby] publishMove SUCCESS: newEventId=${newEventId.take(8)} for game ${startEventId.take(8)}" }
// Update head event ID for next move linking
gameState.updateHeadEventId(newEventId)
@@ -449,6 +537,7 @@ class ChessLobbyLogic(
)
state.setError(null)
} else {
Log.d("chessdebug") { "[Lobby] publishMove FAILED: reverting move ${moveResult.san} for game ${startEventId.take(8)}" }
// Revert the move since publishing failed
gameState.undoLastMove()
@@ -461,6 +550,7 @@ class ChessLobbyLogic(
}
fun resign(startEventId: String) {
Log.d("chessdebug") { "[Lobby] resign: game=${startEventId.take(8)}" }
val gameState = state.getGameState(startEventId) ?: return
if (state.isSpectating(startEventId)) {
@@ -503,7 +593,13 @@ class ChessLobbyLogic(
// Spectator mode
// ========================================
fun stopSpectating(gameId: String) {
state.removeSpectatingGame(gameId)
pollingDelegate.removeGameId(gameId)
}
fun loadGameAsSpectator(startEventId: String) {
Log.d("chessdebug") { "[Lobby] loadGameAsSpectator: game=${startEventId.take(8)}" }
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
@@ -512,6 +608,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
@@ -529,9 +626,11 @@ class ChessLobbyLogic(
}
fun loadGame(startEventId: String) {
Log.d("chessdebug") { "[Lobby] loadGame: game=${startEventId.take(8)}" }
scope.launch(Dispatchers.Default) {
// Don't load if game already exists or was accepted (acceptChallenge will handle it)
if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) {
Log.d("chessdebug") { "[Lobby] loadGame: skipping - already exists or was accepted for ${startEventId.take(8)}" }
return@launch
}
@@ -542,6 +641,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
recentlyLoadedGames[startEventId] = TimeUtils.now()
if (result.liveState.isSpectator) {
state.addSpectatingGame(startEventId, result.liveState)
} else {
@@ -577,16 +677,36 @@ class ChessLobbyLogic(
}
private suspend fun refreshGame(startEventId: String) {
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
val lastLoaded = recentlyLoadedGames[startEventId]
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent fetches for the same game
recentlyLoadedGames[startEventId] = TimeUtils.now()
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.replaceGameState(startEventId, result.liveState)
// Check status FIRST to avoid briefly emitting a finished game through activeGames
val gameStatus = result.liveState.gameStatus.value
if (gameStatus is GameStatus.Finished) {
Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${gameStatus.result}), moving to completed" }
state.moveToCompleted(startEventId, gameStatus.result.notation, null)
pollingDelegate.removeGameId(startEventId)
} else {
Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} updated, moves=${result.liveState.moveHistory.value.size}, isPlayerTurn=${result.liveState.isPlayerTurn()}" }
state.replaceGameState(startEventId, result.liveState)
}
}
is LoadGameResult.Error -> {
Log.d("chessdebug") { "[Lobby] refreshGame: ERROR for game ${startEventId.take(8)}: ${result.message}" }
// Don't overwrite error for periodic refresh failures
}
}
@@ -747,13 +867,27 @@ class ChessLobbyLogic(
val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds
// Also check completed games to avoid re-adding them
val completedGameIds =
state.completedGames.value
.map { it.gameId }
.toSet()
for (startEventId in newGameIds) {
if (startEventId in completedGameIds) continue
if (startEventId in dismissedGameIds) continue
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (!result.liveState.isSpectator) {
recentlyLoadedGames[startEventId] = TimeUtils.now()
// If the discovered game is already finished, send it straight to completed
val gameStatus = result.liveState.gameStatus.value
if (gameStatus is GameStatus.Finished) {
state.moveToCompleted(startEventId, gameStatus.result.notation, null, liveState = result.liveState)
} else if (!result.liveState.isSpectator) {
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
} else {
@@ -815,6 +949,43 @@ class ChessLobbyLogic(
return null
}
fun dismissCompletedGame(gameId: String) {
state.removeCompletedGame(gameId)
dismissedGameIds.add(gameId)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
}
fun dismissAllCompletedGames() {
val allIds = state.completedGames.value.map { it.gameId }
state.clearCompletedGames()
dismissedGameIds.addAll(allIds)
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
}
/**
* Dismiss a finished game from the active/spectating list and move it to completed.
* Called when the user clicks "Continue" on the game end overlay, or automatically
* when a finished game is detected during polling refresh.
*/
fun dismissGame(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
val status = gameState.gameStatus.value
if (status is GameStatus.Finished) {
state.moveToCompleted(gameId, status.result.notation, null)
pollingDelegate.removeGameId(gameId)
}
}
/**
* Remove a game from all lobby lists (active, spectating) and stop polling.
* Used when a game fails to load ("Game not found") so the user can dismiss the stale entry.
*/
fun removeGame(gameId: String) {
state.removeActiveGame(gameId)
state.removeSpectatingGame(gameId)
pollingDelegate.removeGameId(gameId)
}
fun clearError() {
state.setError(null)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameStatus
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -296,7 +297,8 @@ class ChessLobbyState(
}
fun updatePublicGames(games: List<PublicGame>) {
_publicGames.value = games
// Filter out games where the user is a participant
_publicGames.value = games.filter { it.whitePubkey != userPubkey && it.blackPubkey != userPubkey }
}
fun addActiveGame(
@@ -392,65 +394,62 @@ class ChessLobbyState(
}
/**
* Move a game from active/spectating to completed.
* Display names are optional; UI can look them up later if needed.
* Move a game to completed. Looks up the game from active/spectating maps,
* or uses the provided [liveState] if the game isn't in either map yet
* (e.g. a newly discovered game that is already finished).
*/
fun moveToCompleted(
gameId: String,
result: String,
termination: String?,
liveState: LiveChessGameState? = null,
whiteDisplayName: String? = null,
blackDisplayName: String? = null,
) {
val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
existingState?.let { gameState ->
// Derive white/black pubkeys from player color
val whitePubkey =
if (gameState.playerColor == Color.WHITE) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val blackPubkey =
if (gameState.playerColor == Color.BLACK) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val gameState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] ?: liveState ?: return
val completed =
CompletedGame(
gameId = gameId,
whitePubkey = whitePubkey,
whiteDisplayName = whiteDisplayName,
blackPubkey = blackPubkey,
blackDisplayName = blackDisplayName,
result = result,
termination = termination,
moveCount = gameState.moveHistory.value.size,
completedAt =
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
)
val whitePubkey =
if (gameState.playerColor == Color.WHITE) gameState.playerPubkey else gameState.opponentPubkey
val blackPubkey =
if (gameState.playerColor == Color.BLACK) gameState.playerPubkey else gameState.opponentPubkey
_completedGames.update { current ->
// Avoid duplicates
if (current.any { it.gameId == gameId }) {
current
} else {
listOf(completed) + current
}
}
val completed =
CompletedGame(
gameId = gameId,
whitePubkey = whitePubkey,
whiteDisplayName = whiteDisplayName,
blackPubkey = blackPubkey,
blackDisplayName = blackDisplayName,
result = result,
termination = termination,
moveCount = gameState.moveHistory.value.size,
completedAt =
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
)
// Remove from active/spectating
_activeGames.update { it - gameId }
_spectatingGames.update { it - gameId }
// Clear selection if this game was selected
if (_selectedGameId.value == gameId) {
_selectedGameId.value = null
}
_completedGames.update { current ->
if (current.any { it.gameId == gameId }) current else listOf(completed) + current
}
// Remove from active/spectating
_activeGames.update { it - gameId }
_spectatingGames.update { it - gameId }
// Clear selection if this game was selected
if (_selectedGameId.value == gameId) {
_selectedGameId.value = null
}
}
fun removeCompletedGame(gameId: String) {
_completedGames.update { current ->
current.filter { it.gameId != gameId }
}
}
fun clearCompletedGames() {
_completedGames.value = emptyList()
}
fun addSpectatingGame(
@@ -463,6 +462,16 @@ class ChessLobbyState(
_activeGames.update { it + (gameId to state) }
return
}
// Don't add own games to spectating list
// Note: LiveChessGameState.playerPubkey is always viewerPubkey regardless of role,
// so we check isSpectator instead to determine if the user is actually a participant
if (!state.isSpectator) {
return
}
// Don't add finished games to spectating list
if (state.gameStatus.value is GameStatus.Finished) {
return
}
_spectatingGames.update { it + (gameId to state) }
}
@@ -0,0 +1,257 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import androidx.compose.ui.graphics.Color as ComposeColor
private val ActiveBorderColor = ComposeColor(0xFF4CAF50)
private val AvatarOuterSize = 36.dp
private val AvatarInnerSize = 32.dp // 4dp inset for border
/**
* Single player chip showing avatar and name.
*
* @param displayName Player's display name
* @param userHex Player's public key hex (for Robohash fallback)
* @param avatarUrl Optional URL for the player's profile picture
* @param isActive Whether this player's turn is active (shows green border)
* @param mirrored If true, reverses the layout so name appears left of avatar (for right-side player)
* @param modifier Additional modifiers
*/
@Composable
fun ChessPlayerChip(
displayName: String,
userHex: String,
avatarUrl: String?,
isActive: Boolean = false,
mirrored: Boolean = false,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val avatar =
@Composable {
Box(
modifier =
if (isActive) {
Modifier
.size(AvatarOuterSize)
.border(2.dp, ActiveBorderColor, CircleShape)
.clip(CircleShape)
} else {
Modifier
.size(AvatarOuterSize)
.clip(CircleShape)
},
) {
UserAvatar(
userHex = userHex,
pictureUrl = avatarUrl,
size = AvatarInnerSize,
modifier = Modifier.align(Alignment.Center),
)
}
}
val name =
@Composable {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
modifier =
modifier
.alpha(if (isActive) 1f else 0.6f)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (mirrored) {
name()
avatar()
} else {
avatar()
name()
}
}
}
/**
* Full "White vs Black" header for the game board.
*
* Layout:
* ```
* [WhiteAvatar] WhiteName vs BlackName [BlackAvatar]
* White Black
* ```
*
* Active player (based on isWhiteTurn) gets a green avatar border;
* inactive player is slightly dimmed.
*
* @param whiteName Display name for the white player
* @param whiteHex Public key hex for white player (Robohash fallback)
* @param whiteAvatarUrl Optional avatar URL for white player
* @param blackName Display name for the black player
* @param blackHex Public key hex for black player (Robohash fallback)
* @param blackAvatarUrl Optional avatar URL for black player
* @param isWhiteTurn Whether it is currently white's turn
* @param modifier Additional modifiers
*/
@Composable
fun ChessPlayerVsHeader(
whiteName: String,
whiteHex: String,
whiteAvatarUrl: String?,
blackName: String,
blackHex: String,
blackAvatarUrl: String?,
isWhiteTurn: Boolean,
onWhiteClick: (() -> Unit)? = null,
onBlackClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
// White player (left side)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f),
) {
ChessPlayerChip(
displayName = whiteName,
userHex = whiteHex,
avatarUrl = whiteAvatarUrl,
isActive = isWhiteTurn,
onClick = onWhiteClick,
)
Text(
text = "White",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "vs",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Black player (right side) — mirrored so avatar is on the right
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f),
) {
ChessPlayerChip(
displayName = blackName,
userHex = blackHex,
avatarUrl = blackAvatarUrl,
isActive = !isWhiteTurn,
mirrored = true,
onClick = onBlackClick,
)
Text(
text = "Black",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Two overlapping circular avatars for compact game list cards.
*
* The second avatar is offset to overlap the first by 8dp.
*
* @param avatar1Hex Public key hex for first player (Robohash fallback)
* @param avatar2Hex Public key hex for second player (Robohash fallback)
* @param avatar1Url Optional avatar URL for first player
* @param avatar2Url Optional avatar URL for second player
* @param size Diameter of each avatar circle
* @param modifier Additional modifiers
*/
@Composable
fun OverlappingAvatars(
avatar1Hex: String,
avatar2Hex: String,
avatar1Url: String?,
avatar2Url: String?,
size: Dp = 28.dp,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.size(width = size + size - 8.dp, height = size),
) {
UserAvatar(
userHex = avatar1Hex,
pictureUrl = avatar1Url,
size = size,
)
Box(
modifier = Modifier.offset(x = size - 8.dp),
) {
// Thin border to visually separate overlapping avatars
Box(
modifier =
Modifier
.size(size)
.background(MaterialTheme.colorScheme.surface, CircleShape),
)
UserAvatar(
userHex = avatar2Hex,
pictureUrl = avatar2Url,
size = size,
)
}
}
}
@@ -117,6 +117,7 @@ fun InteractiveChessBoard(
val isLegalMove = legalMoves.contains(square)
val showCoord = if (flipped) rank == 7 else rank == 0
val showRankLabel = if (flipped) file == 7 else file == 0
InteractiveChessSquare(
piece = piece,
@@ -125,6 +126,7 @@ fun InteractiveChessBoard(
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = showCoord,
showRankLabel = showRankLabel,
file = file,
rank = rank,
onClick = {
@@ -231,6 +233,7 @@ private fun InteractiveChessSquare(
isSelected: Boolean,
isLegalMove: Boolean,
showCoordinate: Boolean,
showRankLabel: Boolean,
file: Int,
rank: Int,
onClick: () -> Unit,
@@ -286,6 +289,20 @@ private fun InteractiveChessSquare(
)
}
// Show rank label (1-8) on left file
if (showRankLabel) {
Text(
text = (rank + 1).toString(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5),
modifier =
Modifier
.align(Alignment.TopStart)
.padding(2.dp),
)
}
// Show file coordinate (a-h) on bottom rank
if (showCoordinate) {
Text(
@@ -201,6 +201,14 @@ fun LiveChessGameScreen(
onResign: () -> Unit,
isSpectatorOverride: Boolean? = null,
onGameEndDismiss: (() -> Unit)? = null,
onLeaveSpectating: (() -> Unit)? = null,
whiteName: String = "White",
whiteHex: String = "",
whiteAvatarUrl: String? = null,
blackName: String = "Black",
blackHex: String = "",
blackAvatarUrl: String? = null,
onPlayerClick: ((pubkeyHex: String) -> Unit)? = null,
) {
// Observe state flows for automatic recomposition on updates
val currentPosition by gameState.currentPosition.collectAsState()
@@ -210,8 +218,9 @@ fun LiveChessGameScreen(
// Use override if provided, otherwise fall back to gameState flag
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
// Pending challenges and spectators cannot make moves
val canMakeMoves = !isSpectator && !gameState.isPendingChallenge
// Spectators cannot make moves; pending challenges still allow board interaction
// (matches Desktop behavior where board is always interactive for participants)
val canMakeMoves = !isSpectator
// Track if game end overlay was dismissed
var gameEndDismissed by remember { mutableStateOf(false) }
@@ -235,6 +244,22 @@ fun LiveChessGameScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Player vs Player avatar header
if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) {
ChessPlayerVsHeader(
whiteName = whiteName,
whiteHex = whiteHex,
whiteAvatarUrl = whiteAvatarUrl,
blackName = blackName,
blackHex = blackHex,
blackAvatarUrl = blackAvatarUrl,
isWhiteTurn = currentPosition.activeColor == Color.WHITE,
onWhiteClick = onPlayerClick?.let { { it(whiteHex) } },
onBlackClick = onPlayerClick?.let { { it(blackHex) } },
modifier = Modifier.fillMaxWidth(),
)
}
// Game info - use currentPosition.activeColor for turn display
GameInfoHeader(
gameId = gameState.startEventId,
@@ -278,7 +303,7 @@ fun LiveChessGameScreen(
}
isSpectator -> {
SpectatorInfo()
SpectatorInfo(onLeaveSpectating = onLeaveSpectating)
}
else -> {
@@ -526,22 +551,33 @@ private fun GameInfoHeader(
* Info banner shown when spectating a game
*/
@Composable
private fun SpectatorInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
private fun SpectatorInfo(onLeaveSpectating: (() -> Unit)? = null) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
onLeaveSpectating?.let { onLeave ->
OutlinedButton(onClick = onLeave) {
Text("Leave Game")
}
}
}
}
@@ -25,6 +25,13 @@ import androidx.compose.ui.text.TextRange
import kotlin.math.max
import kotlin.math.min
fun TextFieldState.setTextAndPlaceCursorAtBeginning(text: String) {
edit {
replace(0, length, text)
selection = TextRange(0, 0)
}
}
fun TextFieldState.insertUrlAtCursor(url: String) {
edit {
var toInsert = url.trim()
@@ -53,7 +53,7 @@ object SearchResultFilter {
}
// Sort by createdAt descending
return result.sortedByDescending { it.createdAt }
return result.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
}
fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" }
@@ -33,16 +33,16 @@ object SearchResultSorter {
): List<Event> =
when (order) {
SearchSortOrder.NEWEST -> {
events.sortedByDescending { it.createdAt }
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
}
SearchSortOrder.OLDEST -> {
events.sortedBy { it.createdAt }
events.sortedWith(compareBy<Event> { it.createdAt }.thenBy { it.id })
}
SearchSortOrder.RELEVANCE -> {
if (searchText.isBlank()) {
events.sortedByDescending { it.createdAt }
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
} else {
events.sortedByDescending { scoreEvent(it, searchText) }
}
@@ -75,4 +75,4 @@ sealed class CardFeedState {
*/
val DefaultCardComparator: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }
.thenByDescending { it.id() }
.thenBy { it.id() }
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
/**
@@ -67,11 +68,15 @@ class ChessEventBroadcaster(
val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet()
val subId = newSubId()
Log.d("chessdebug") { "[Broadcaster] broadcast: event=${event.id.take(8)}, kind=${event.kind}, targetRelays=${targetRelays.map { it.url }}" }
// Step 1: Check which relays are already connected
val initialConnected = client.connectedRelaysFlow().value
val alreadyConnected = targetRelays.intersect(initialConnected)
val needsConnection = targetRelays - alreadyConnected
Log.d("chessdebug") { "[Broadcaster] connected=${alreadyConnected.map { it.url }}, needsConnection=${needsConnection.map { it.url }}" }
// Step 2: If some relays need connection, open a subscription to trigger it
if (needsConnection.isNotEmpty()) {
// Use a valid filter with recent since timestamp to trigger connection
@@ -110,8 +115,11 @@ class ChessEventBroadcaster(
}
// Step 3: Send the event and wait for OK responses
Log.d("chessdebug") { "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)" }
val success = client.publishAndConfirm(event, targetRelays, timeoutSeconds)
Log.d("chessdebug") { "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}" }
// Note: publishAndConfirm only returns aggregate success (any relay accepted)
// We don't have per-relay results, so relayResults is empty
return BroadcastResult(
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import java.util.prefs.Preferences
actual class ChessDismissedGamesStorage private actual constructor() {
private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java)
actual companion object {
private const val NODE_PREFIX = "chess_dismissed_"
private const val DELIMITER = ","
actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
}
actual fun load(userPubkey: String): Set<String> {
val raw = prefs.get("$NODE_PREFIX$userPubkey", "")
if (raw.isEmpty()) return emptySet()
return raw.split(DELIMITER).toSet()
}
actual fun save(
userPubkey: String,
ids: Set<String>,
) {
if (ids.isEmpty()) {
prefs.remove("$NODE_PREFIX$userPubkey")
} else {
prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER))
}
}
}
@@ -53,6 +53,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -66,14 +67,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard
import com.vitorpamplona.amethyst.commons.chess.ChallengeCard
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessConfig
import com.vitorpamplona.amethyst.commons.chess.ChessPlayerVsHeader
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.CompletedGame
import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard
import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard
import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars
import com.vitorpamplona.amethyst.commons.chess.PublicGame
import com.vitorpamplona.amethyst.commons.chess.PublicGameCard
import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -82,6 +91,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionW
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
/**
@@ -110,7 +120,7 @@ fun ChessScreen(
ChessConfig.CHESS_RELAYS.forEach { relayUrl ->
relayManager.addRelay(relayUrl)
}
println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager")
Log.d("chessdebug") { "[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager" }
}
// Derive stable keys to avoid recomposition from LiveChessGameState identity changes
@@ -251,8 +261,12 @@ fun ChessScreen(
// Main content
if (selectedGameId != null) {
// Set focused game mode - only poll this game, not others
// If game isn't in active/spectating maps (e.g. completed game), load from relays
LaunchedEffect(selectedGameId) {
viewModel.setFocusedGame(selectedGameId!!)
if (viewModel.getGameState(selectedGameId!!) == null) {
viewModel.loadGame(selectedGameId!!)
}
}
// Use stateVersion to ensure recomposition when game state changes
@@ -267,6 +281,10 @@ fun ChessScreen(
val wasAccepted = viewModel.wasAccepted(selectedGameId!!)
val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId)
val isPlayerWhite = gameState.playerColor == ChessColor.WHITE
val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey
val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey
DesktopChessGameLayout(
gameState = gameState,
opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey),
@@ -276,7 +294,22 @@ fun ChessScreen(
},
onResign = { viewModel.resign(gameState.startEventId) },
isSpectatorOverride = isSpectating,
onLeaveSpectating =
if (isSpectating) {
{
viewModel.stopSpectating(gameState.startEventId)
viewModel.selectGame(null)
}
} else {
null
},
compactMode = compactMode,
whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey),
whiteHex = whitePubkey,
whiteAvatarUrl = viewModel.userMetadataCache.getPictureUrl(whitePubkey),
blackName = viewModel.userMetadataCache.getDisplayName(blackPubkey),
blackHex = blackPubkey,
blackAvatarUrl = viewModel.userMetadataCache.getPictureUrl(blackPubkey),
)
}
} else {
@@ -308,6 +341,8 @@ fun ChessScreen(
onOpenOwnChallenge = { viewModel.openOwnChallenge(it) },
onWatchGame = { viewModel.loadGameAsSpectator(it) },
onSelectGame = { viewModel.selectGame(it) },
onDismissGame = { viewModel.dismissCompletedGame(it) },
onDismissAllGames = { viewModel.dismissAllCompletedGames() },
listState = listState,
)
}
@@ -341,6 +376,8 @@ private fun ChessLobby(
onOpenOwnChallenge: (ChessChallenge) -> Unit,
onWatchGame: (String) -> Unit,
onSelectGame: (String) -> Unit,
onDismissGame: (String) -> Unit,
onDismissAllGames: () -> Unit,
listState: LazyListState = rememberLazyListState(),
) {
val hasContent =
@@ -391,16 +428,17 @@ private fun ChessLobby(
}
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
com.vitorpamplona.amethyst.commons.chess.ActiveGameCard(
ActiveGameCard(
gameId = gameId,
opponentName = metadataCache.getDisplayName(state.opponentPubkey),
isYourTurn = state.isPlayerTurn(),
onClick = { onSelectGame(gameId) },
avatar = {
UserAvatar(
userHex = state.opponentPubkey,
pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey),
size = 40.dp,
OverlappingAvatars(
avatar1Hex = state.playerPubkey,
avatar2Hex = state.opponentPubkey,
avatar1Url = metadataCache.getPictureUrl(state.playerPubkey),
avatar2Url = metadataCache.getPictureUrl(state.opponentPubkey),
)
},
)
@@ -421,17 +459,18 @@ private fun ChessLobby(
}
items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge ->
com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard(
OutgoingChallengeCard(
opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) },
userPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
onClick = { onOpenOwnChallenge(challenge) },
avatar =
challenge.opponentPubkey?.let { pubkey ->
{
UserAvatar(
userHex = pubkey,
pictureUrl = metadataCache.getPictureUrl(pubkey),
size = 40.dp,
OverlappingAvatars(
avatar1Hex = userPubkey,
avatar2Hex = pubkey,
avatar1Url = metadataCache.getPictureUrl(userPubkey),
avatar2Url = metadataCache.getPictureUrl(pubkey),
)
}
},
@@ -453,7 +492,7 @@ private fun ChessLobby(
items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) ->
val moveCount by state.moveHistory.collectAsState()
com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard(
SpectatingGameCard(
moveCount = moveCount.size,
onClick = { onSelectGame(gameId) },
)
@@ -474,16 +513,17 @@ private fun ChessLobby(
}
items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge ->
com.vitorpamplona.amethyst.commons.chess.ChallengeCard(
ChallengeCard(
challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey),
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = true,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
UserAvatar(
userHex = challenge.challengerPubkey,
pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
size = 40.dp,
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
avatar2Url = metadataCache.getPictureUrl(userPubkey),
)
},
)
@@ -504,16 +544,17 @@ private fun ChessLobby(
}
items(openChallenges, key = { "open-${it.eventId}" }) { challenge ->
com.vitorpamplona.amethyst.commons.chess.ChallengeCard(
ChallengeCard(
challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey),
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = false,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
UserAvatar(
userHex = challenge.challengerPubkey,
pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
size = 40.dp,
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
avatar2Url = metadataCache.getPictureUrl(userPubkey),
)
},
)
@@ -533,7 +574,7 @@ private fun ChessLobby(
}
items(publicGames, key = { "public-${it.gameId}" }) { game ->
com.vitorpamplona.amethyst.commons.chess.PublicGameCard(
PublicGameCard(
whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey),
blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey),
moveCount = game.moveCount,
@@ -546,27 +587,39 @@ private fun ChessLobby(
if (completedGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Recent Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Recent Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (completedGames.size >= 2) {
TextButton(onClick = onDismissAllGames) {
Text("Clear all")
}
}
}
}
items(
completedGames.distinctBy { it.gameId }.take(10),
completedGames.take(10),
key = { "completed-${it.gameId}-${it.completedAt}" },
) { game ->
// Derive opponent pubkey based on who the user is
val opponentPubkey =
if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey
com.vitorpamplona.amethyst.commons.chess.CompletedGameCard(
CompletedGameCard(
opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey),
result = game.result,
didUserWin = game.didUserWin(userPubkey),
isDraw = game.isDraw,
moveCount = game.moveCount,
onClick = { onSelectGame(game.gameId) },
onDismiss = { onDismissGame(game.gameId) },
avatar = {
UserAvatar(
userHex = opponentPubkey,
@@ -599,7 +652,14 @@ private fun DesktopChessGameLayout(
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
isSpectatorOverride: Boolean? = null,
onLeaveSpectating: (() -> Unit)? = null,
compactMode: Boolean = false,
whiteName: String = "White",
whiteHex: String = "",
whiteAvatarUrl: String? = null,
blackName: String = "Black",
blackHex: String = "",
blackAvatarUrl: String? = null,
) {
// Collect state flows to trigger recomposition on changes
val currentPosition by gameState.currentPosition.collectAsState()
@@ -627,20 +687,39 @@ private fun DesktopChessGameLayout(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp),
) {
// Left side: Chess board + toggle
// Left side: Chess board + toggle + vs header
Box(
modifier = Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
InteractiveChessBoard(
engine = engine,
boardSize = boardSize,
flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
playerColor = playerColor,
isSpectator = isSpectator,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Player vs Player header above board
if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) {
ChessPlayerVsHeader(
whiteName = whiteName,
whiteHex = whiteHex,
whiteAvatarUrl = whiteAvatarUrl,
blackName = blackName,
blackHex = blackHex,
blackAvatarUrl = blackAvatarUrl,
isWhiteTurn = currentPosition.activeColor == ChessColor.WHITE,
modifier = Modifier.fillMaxWidth(),
)
}
InteractiveChessBoard(
engine = engine,
boardSize = boardSize,
flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
playerColor = playerColor,
isSpectator = isSpectator,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade,
)
}
// Toggle button anchored to top-end of board area
IconButton(
@@ -820,16 +899,28 @@ private fun DesktopChessGameLayout(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
),
) {
Row(
Column(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
}
onLeaveSpectating?.let { onLeave ->
OutlinedButton(
onClick = onLeave,
modifier = Modifier.fillMaxWidth(),
) {
Text("Leave Game")
}
}
}
}
}
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
/**
* Desktop implementation of ChessEventPublisher using Jester protocol.
@@ -63,9 +64,9 @@ class DesktopChessPublisher(
* Uses ChessEventBroadcaster to ensure relay connections before sending.
*/
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
Log.d("chessdebug") { "[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
val result = broadcaster.broadcast(event)
println("[DesktopChessPublisher] Broadcast result: ${result.message}")
Log.d("chessdebug") { "[DesktopChessPublisher] Broadcast result: ${result.message}" }
return result.success
}
@@ -93,7 +94,7 @@ class DesktopChessPublisher(
val success = broadcastToChessRelays(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[DesktopChessPublisher] publishStart failed: ${e.message}")
Log.d("chessdebug") { "[DesktopChessPublisher] publishStart failed: ${e.message}" }
null
}
@@ -116,7 +117,7 @@ class DesktopChessPublisher(
val success = broadcastToChessRelays(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[DesktopChessPublisher] publishMove failed: ${e.message}")
Log.d("chessdebug") { "[DesktopChessPublisher] publishMove failed: ${e.message}" }
null
}
@@ -139,7 +140,7 @@ class DesktopChessPublisher(
val signedEvent = account.signer.sign(template)
broadcastToChessRelays(signedEvent)
} catch (e: Exception) {
println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}")
Log.d("chessdebug") { "[DesktopChessPublisher] publishGameEnd failed: ${e.message}" }
false
}
@@ -169,7 +170,7 @@ class DesktopRelayFetcher(
private fun chessRelayUrls(): List<NormalizedRelayUrl> = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }
override fun getRelayUrls(): List<String> {
println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
Log.d("chessdebug") { "[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
return ChessConfig.CHESS_RELAYS
}
@@ -182,11 +183,11 @@ class DesktopRelayFetcher(
* 2. Fetch moves that reference the start event via #e tag
*/
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId" }
// Query cache first (populated by subscription)
val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId)
println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}" }
// Also do relay query to catch any new events and populate cache
// Filter 1: Fetch the start event by its ID
@@ -220,7 +221,7 @@ class DesktopRelayFetcher(
// Merge: prefer cache start event, merge all moves
val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id }
println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}" }
return JesterGameEvents(
startEvent = cachedEvents.startEvent ?: relayStartEvent,
@@ -233,7 +234,7 @@ class DesktopRelayFetcher(
*/
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
val relays = chessRelayUrls()
println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays" }
// Query cache for start events that are:
// 1. Open challenges (no specific opponent)
@@ -246,7 +247,7 @@ class DesktopRelayFetcher(
event.pubKey == userPubkey
}
println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache" }
// Also do relay query to catch any new challenges
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
@@ -264,7 +265,7 @@ class DesktopRelayFetcher(
// Merge and deduplicate
val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id }
println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge" }
return allStartEvents
}
@@ -273,7 +274,7 @@ class DesktopRelayFetcher(
*/
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
val relays = chessRelayUrls()
println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays" }
val gameIds = mutableSetOf<String>()
@@ -291,7 +292,7 @@ class DesktopRelayFetcher(
event.startEventId()?.let { gameIds.add(it) }
}
println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache" }
// Also do relay query to catch any new game IDs
val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
@@ -314,7 +315,7 @@ class DesktopRelayFetcher(
}
}
println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge")
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge" }
return gameIds
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
@@ -59,6 +60,7 @@ class DesktopChessViewModelNew(
private val publisher = DesktopChessPublisher(account, relayManager)
private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex)
private val metadataProvider = DesktopMetadataProvider(userMetadataCache)
private val dismissedStorage = ChessDismissedGamesStorage.create()
// Shared business logic (creates its own ChessLobbyState internally)
private val logic =
@@ -69,6 +71,7 @@ class DesktopChessViewModelNew(
metadataProvider = metadataProvider,
scope = scope,
pollingConfig = ChessPollingDefaults.desktop,
dismissedStorage = dismissedStorage,
)
// ============================================
@@ -162,6 +165,12 @@ class DesktopChessViewModelNew(
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
fun dismissGame(gameId: String) = logic.dismissGame(gameId)
fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId)
fun dismissAllCompletedGames() = logic.dismissAllCompletedGames()
// ============================================
// Spectator operations
// ============================================
@@ -170,7 +179,9 @@ class DesktopChessViewModelNew(
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
fun removeGame(gameId: String) = logic.removeGame(gameId)
// ============================================
// Utility
@@ -196,7 +196,7 @@ fun ReadsScreen(
remember {
EventCollectionState<LongTextNoteEvent>(
getId = { it.id },
sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt },
sortComparator = compareByDescending<LongTextNoteEvent> { it.publishedAt() ?: it.createdAt }.thenBy { it.id },
maxSize = 100,
scope = scope,
)
@@ -829,7 +829,7 @@ fun UserProfileScreen(
}
} else {
items(
articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt },
articleEvents.sortedWith(compareByDescending<LongTextNoteEvent> { it.publishedAt() ?: it.createdAt }.thenBy { it.id }),
key = { "art-${it.id}" },
) { article ->
LongFormCard(
@@ -871,7 +871,7 @@ fun UserProfileScreen(
}
} else {
items(
highlightEvents.sortedByDescending { it.createdAt },
highlightEvents.sortedWith(compareByDescending<HighlightEvent> { it.createdAt }.thenBy { it.id }),
key = { "hl-${it.id}" },
) { highlight ->
PublishedHighlightCard(
@@ -92,7 +92,10 @@ class LabeledBookmarkListEvent(
const val ALT = "A labeled list of bookmarks"
@OptIn(ExperimentalUuidApi::class)
fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, Uuid.random().toString())
fun createAddress(
pubKey: HexKey,
dTag: String,
) = Address(KIND, pubKey, dTag)
suspend fun create(
title: String = "",
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
/**
* Deterministic chess state reconstruction from Jester protocol events.
@@ -59,16 +60,23 @@ object ChessStateReconstructor {
events: JesterGameEvents,
viewerPubkey: String,
): ReconstructionResult {
Log.d("chessdebug", "[Reconstructor] reconstruct() called: viewerPubkey=${viewerPubkey.take(8)}, startEvent=${events.startEvent?.id?.take(8)}, moveCount=${events.moves.size}")
// Step 1: Get start event (required for player info)
val startEvent =
events.startEvent
?: return ReconstructionResult.Error("No start event found")
?: run {
Log.d("chessdebug", "[Reconstructor] ERROR: No start event found")
return ReconstructionResult.Error("No start event found")
}
val startEventId = startEvent.id
val challengerPubkey = startEvent.pubKey
val challengerColor = startEvent.playerColor() ?: Color.WHITE
val challengedPubkey = startEvent.opponentPubkey()
Log.d("chessdebug", "[Reconstructor] startEventId=${startEventId.take(8)}, challenger=${challengerPubkey.take(8)}, challengerColor=$challengerColor, challengedPubkey=${challengedPubkey?.take(8)}")
// In Jester, we determine opponent from the p-tag or from move events
// For open challenges, we need to find who made the first move
val opponentFromMoves =
@@ -77,6 +85,7 @@ object ChessStateReconstructor {
?.pubKey
val actualOpponent = challengedPubkey ?: opponentFromMoves
Log.d("chessdebug", "[Reconstructor] opponentFromMoves=${opponentFromMoves?.take(8)}, actualOpponent=${actualOpponent?.take(8)}")
// Determine players based on challenger's color choice
val (whitePubkey, blackPubkey) =
@@ -108,6 +117,8 @@ object ChessStateReconstructor {
ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black
}
Log.d("chessdebug", "[Reconstructor] white=${whitePubkey?.take(8)}, black=${blackPubkey?.take(8)}, viewerRole=$viewerRole, playerColor=$playerColor")
// Check if game is pending (no moves yet AND viewer is the challenger)
// If viewer accepted someone else's challenge, the game is NOT pending
val isChallenger = viewerPubkey == startEvent.pubKey
@@ -118,6 +129,11 @@ object ChessStateReconstructor {
val latestMove = events.latestMove()
val history = latestMove?.history() ?: emptyList()
Log.d("chessdebug", "[Reconstructor] latestMove=${latestMove?.id?.take(8)}, historySize=${history.size}, isPendingChallenge=$isPendingChallenge")
if (history.isNotEmpty()) {
Log.d("chessdebug", "[Reconstructor] history: ${history.joinToString(" ")}")
}
// Track applied moves
val appliedMoveNumbers = mutableSetOf<Int>()
var isDesynced = false
@@ -131,8 +147,10 @@ object ChessStateReconstructor {
} else {
// Move failed - game might be desynced
isDesynced = true
Log.d("chessdebug", "[Reconstructor] DESYNC: move #$moveNumber '$san' failed: ${result.error}")
// Try to recover by loading the FEN from latest move if available
latestMove?.fen()?.let { fen ->
Log.d("chessdebug", "[Reconstructor] Recovering with FEN: $fen")
engine.loadFen(fen)
}
break
@@ -144,6 +162,7 @@ object ChessStateReconstructor {
val currentFen = engine.getFen()
if (!fenPositionsMatch(currentFen, expectedFen)) {
isDesynced = true
Log.d("chessdebug", "[Reconstructor] FEN MISMATCH: current=$currentFen, expected=$expectedFen")
engine.loadFen(expectedFen)
}
}
@@ -154,17 +173,20 @@ object ChessStateReconstructor {
when {
gameResult != null -> {
val result = parseGameResult(gameResult)
Log.d("chessdebug", "[Reconstructor] Game finished from result tag: $gameResult -> $result")
GameStatus.Finished(result)
}
engine.isCheckmate() -> {
val winner = engine.getSideToMove().opposite()
Log.d("chessdebug", "[Reconstructor] Checkmate detected, winner=$winner")
GameStatus.Finished(
if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS,
)
}
engine.isStalemate() -> {
Log.d("chessdebug", "[Reconstructor] Stalemate detected")
GameStatus.Finished(GameResult.DRAW)
}
@@ -197,6 +219,8 @@ object ChessStateReconstructor {
timeControl = null, // Not supported in Jester
)
Log.d("chessdebug", "[Reconstructor] Result: status=$gameStatus, appliedMoves=${appliedMoveNumbers.size}, isDesynced=$isDesynced, headEvent=${headEventId.take(8)}")
return ReconstructionResult.Success(state, engine)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip64Chess
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -125,12 +126,15 @@ class LiveChessGameState(
to: String,
promotion: PieceType? = null,
): ChessMoveEvent? {
Log.d("chessdebug", "[LiveGame] makeMove: game=${startEventId.take(8)}, from=$from, to=$to, promotion=$promotion, isPlayerTurn=${isPlayerTurn()}, status=${_gameStatus.value}")
if (!isPlayerTurn()) {
Log.d("chessdebug", "[LiveGame] makeMove REJECTED: not player's turn (sideToMove=${engine.getSideToMove()}, playerColor=$playerColor)")
_lastError.value = "Not your turn"
return null
}
if (_gameStatus.value != GameStatus.InProgress) {
Log.d("chessdebug", "[LiveGame] makeMove REJECTED: game not in progress (${_gameStatus.value})")
_lastError.value = "Game is not in progress"
return null
}
@@ -171,6 +175,7 @@ class LiveChessGameState(
* @param newHeadEventId The ID of the newly published move event
*/
fun updateHeadEventId(newHeadEventId: String) {
Log.d("chessdebug", "[LiveGame] updateHeadEventId: game=${startEventId.take(8)}, old=${_headEventId.value.take(8)}, new=${newHeadEventId.take(8)}")
_headEventId.value = newHeadEventId
}
@@ -204,8 +209,10 @@ class LiveChessGameState(
fen: String,
moveNumber: Int? = null,
): Boolean {
Log.d("chessdebug", "[LiveGame] applyOpponentMove: game=${startEventId.take(8)}, san=$san, moveNumber=$moveNumber, expectedMove=${_moveHistory.value.size + 1}")
// Check for duplicate move
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
Log.d("chessdebug", "[LiveGame] applyOpponentMove: DUPLICATE move #$moveNumber, ignoring")
// Already processed this move, ignore
return true
}
@@ -213,12 +220,14 @@ class LiveChessGameState(
// Check for out-of-order move
val expectedMoveNumber = _moveHistory.value.size + 1
if (moveNumber != null && moveNumber > expectedMoveNumber) {
Log.d("chessdebug", "[LiveGame] applyOpponentMove: OUT-OF-ORDER move #$moveNumber (expected #$expectedMoveNumber), queuing")
// Store for later processing
pendingMoves[moveNumber] = san to fen
return true
}
if (isPlayerTurn()) {
Log.d("chessdebug", "[LiveGame] applyOpponentMove: REJECTED - it's player's turn, not opponent's")
_lastError.value = "Received move but it's not opponent's turn"
return false
}
@@ -237,11 +246,13 @@ class LiveChessGameState(
if (currentFen != fen) {
// Positions don't match - desync detected
_isDesynced.value = true
Log.d("chessdebug", "[LiveGame] applyOpponentMove: FEN MISMATCH after $san - current=$currentFen, expected=$fen")
_lastError.value = "Position mismatch - syncing to opponent's position"
// Load the opponent's FEN to stay in sync
engine.loadFen(fen)
} else {
_isDesynced.value = false
Log.d("chessdebug", "[LiveGame] applyOpponentMove: SUCCESS - $san applied, totalMoves=${_moveHistory.value.size + 1}")
}
_currentPosition.value = engine.getPosition()
@@ -260,6 +271,7 @@ class LiveChessGameState(
return true
} else {
Log.d("chessdebug", "[LiveGame] applyOpponentMove: INVALID move $san - ${result.error}")
_lastError.value = "Invalid opponent move: $san"
return false
}
@@ -512,6 +524,7 @@ class LiveChessGameState(
* Used when loading a game from cache that already has an end event.
*/
fun markAsFinished(result: GameResult) {
Log.d("chessdebug", "[LiveGame] markAsFinished: game=${startEventId.take(8)}, result=$result")
_gameStatus.value = GameStatus.Finished(result)
}
@@ -24,6 +24,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BleChunkAssemblerTest {
@Test
@@ -43,7 +44,7 @@ class BleChunkAssemblerTest {
val assembler = BleChunkAssembler()
val message = """["EVENT",{"content":"hello world from nostr ble mesh networking"}]"""
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10)
assert(chunks.size > 1)
assertTrue(chunks.size > 1)
for (i in 0 until chunks.size - 1) {
val result = assembler.addChunk(chunks[i])