diff --git a/.gitignore b/.gitignore index bece7072e..8751fd092 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ TASKS.md desktopApp/src/jvmMain/appResources/linux/ desktopApp/src/jvmMain/appResources/macos/ desktopApp/src/jvmMain/appResources/windows/ + +# Git worktrees +.worktrees/ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3d8f6f024..164f26858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index d1696c702..970f5e80d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 9c16a2e64..ed5e76ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index ae6fc1831..220987e65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index eedf9a4af..970d910e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 29df2cae1..941b972fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 40bcb20fc..3b6149d98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 24f6fc752..2bd2817af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index a488bde7a..ca54a47cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 066de85c4..f340dec51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index 5a532819b..3a49b5e1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -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 { - 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 { 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 { - 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 99f957911..dd71450ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -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)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 96a2502eb..f664a51af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt index b4c33a68d..ea1e19164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -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 create(modelClass: Class): T { if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { - return ChessViewModelNew(account) as T + return ChessViewModelNew(account, application) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index a5ab59bba..fce1c2a2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 3a1cccd1d..01616b65a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -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, ) : PerUniqueIdEoseManager(client, allKeys) { + var onChessEvent: ((Event) -> Unit)? = null + override fun updateFilter( key: ChessQueryState, since: SincePerRelayMap?, ): List = 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?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + onChessEvent?.invoke(event) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt index 04a4fc5b9..a083cbf0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt @@ -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() { - 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt index 11e955ef2..acbe21fc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index ebf796682..c83f7480d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + override fun sort(items: Set): List = items.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index 0a5e34b9e..26bc07716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index d096a1917..71e8bacab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index cf7b5a9fc..e2edca451 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -262,8 +262,7 @@ class CardFeedContentState( val sortedList = singleList .get(string) - ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - ?.reversed() + ?.sortedWith(compareByDescending { 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 { 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 { it.createdAt() }.thenBy { it.id() }) } private fun updateFeed(notes: ImmutableList) { @@ -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 { it.createdAt() }.thenBy { it.id() }) .take(localFilter.limit()) .toImmutableList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt index 83b626a13..ede972e7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt @@ -80,6 +80,6 @@ class OpenPollsState( false } } - }.sortedByDescending { it.createdAt() } + }.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index be50e213d..f5ec41e6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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)) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt index 3de2439f2..d2a6da29f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt @@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() { sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed", ) - LocalCache.consume(releaseNotes, null, true) + LocalCache.consumeRegularEvent(releaseNotes, null, true) } val accountViewModel = mockAccountViewModel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 3781515ff..e156287cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index e25508d0e..013339885 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -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) } diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e3bca072b..0ef2ee2d2 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1622,6 +1622,7 @@ Test połączenia Testowanie serwerów… Podłączony + Test nieudany Wyniki testu Diagnostyka Ostatni test diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index ac2075f8a..389d7b3bd 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -655,6 +655,11 @@ 标记为已读 新信息 新打闪 + 国际象棋 + 提醒您有关国际象棋游戏的事件 + %1$s 接受了您的国际象棋挑战 + %1$s 已移动 —— 轮到您了 + 国际象棋更新 通知: 加入对话 用户或群组 ID diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c11be6263..26c6c2eb7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -751,6 +751,19 @@ New messages New zaps + ReactionsID + Reactions + Notifies you when somebody reacts to your post + %1$s reacted to your post + for %1$s + New reactions + ChessID + Chess + Notifies you about chess game events + %1$s accepted your chess challenge + %1$s moved — your turn + Chess updates + Notify: Join Conversation diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..b4e599a36 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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 = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet() + + actual fun save( + userPubkey: String, + ids: Set, + ) { + prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt index dc0cec5bb..66191aff2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..1567a00df --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -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 + + fun save( + userPubkey: String, + ids: Set, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 8a4575f3f..9c9b0e676 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -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) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt index f73db65d9..4cc76c5ee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt index ea6631791..56671e660 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt index e9f23f8f7..afcae46a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -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, + ) + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 87e7bd9ed..8735c8ec0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -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 = + 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() + + // Dedup incoming events (same event delivered by multiple relays) + // Bounded LRU: evict oldest when exceeding capacity + private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet()) + 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 5ebcd39f0..f60b777d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -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) { - _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) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt new file mode 100644 index 000000000..a363f3e70 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt @@ -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, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt index c91613b26..d103bb747 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index c04a06f36..09282773e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -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") + } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt index 6b74453af..c669c7fbd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt @@ -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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt index 6cff2d8cf..c0a73021e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt @@ -53,7 +53,7 @@ object SearchResultFilter { } // Sort by createdAt descending - return result.sortedByDescending { it.createdAt } + return result.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index bc1ea8177..04e92ebec 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -33,16 +33,16 @@ object SearchResultSorter { ): List = when (order) { SearchSortOrder.NEWEST -> { - events.sortedByDescending { it.createdAt } + events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } SearchSortOrder.OLDEST -> { - events.sortedBy { it.createdAt } + events.sortedWith(compareBy { it.createdAt }.thenBy { it.id }) } SearchSortOrder.RELEVANCE -> { if (searchText.isBlank()) { - events.sortedByDescending { it.createdAt } + events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } else { events.sortedByDescending { scoreEvent(it, searchText) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt index b2424cd7f..cc46f38a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt @@ -75,4 +75,4 @@ sealed class CardFeedState { */ val DefaultCardComparator: Comparator = compareByDescending { it.createdAt() } - .thenByDescending { it.id() } + .thenBy { it.id() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index 0293c58d1..1d68c03ef 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -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( diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..490420da7 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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 { + val raw = prefs.get("$NODE_PREFIX$userPubkey", "") + if (raw.isEmpty()) return emptySet() + return raw.split(DELIMITER).toSet() + } + + actual fun save( + userPubkey: String, + ids: Set, + ) { + if (ids.isEmpty()) { + prefs.remove("$NODE_PREFIX$userPubkey") + } else { + prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER)) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index fcea39114..b144fd9c7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -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") + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt index d20a2247b..3f2c93595 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -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 = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) } override fun getRelayUrls(): List { - 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 { 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 { 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() @@ -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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index b329a8c27..c77c18650 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 42f3ad82a..7ff4b2b51 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -196,7 +196,7 @@ fun ReadsScreen( remember { EventCollectionState( getId = { it.id }, - sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }, + sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }, maxSize = 100, scope = scope, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 914f6fcbd..1b61d4772 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -829,7 +829,7 @@ fun UserProfileScreen( } } else { items( - articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt }, + articleEvents.sortedWith(compareByDescending { 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 { it.createdAt }.thenBy { it.id }), key = { "hl-${it.id}" }, ) { highlight -> PublishedHighlightCard( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt index 6d4e03c82..04c3b7c01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -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 = "", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 4dd69fbd4..297ca80d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -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() 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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt index 7e0daed77..7a49956ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -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) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt index 523ff36ec..9b8f760df 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt @@ -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])