Merge branch 'main' into claude/migrate-quartz-nip58-XQ8bw
This commit is contained in:
@@ -163,3 +163,6 @@ TASKS.md
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -580,84 +580,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ExternalIdentitiesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ContactListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BookmarkListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TextNoteEvent,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PublicMessageEvent,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TorrentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStoryPrologueEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStorySceneEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStoryReadingStateEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorRecommendationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorProficiencyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consumeRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -694,135 +616,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PictureEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: VoiceEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: VoiceReplyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90StatusEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: RequestToVanishEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GoalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitPatchEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitIssueEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun consume(
|
||||
event: GitReplyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Chess events (NIP-64 live chess)
|
||||
fun consume(
|
||||
event: ChessGameEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: JesterEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameChallengeEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameAcceptEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessMoveEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameEndEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessDrawOfferEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NipTextEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1103,12 +896,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ZapPollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: LiveActivitiesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1150,234 +937,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LabeledBookmarkListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: MuteListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: CommunityListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitRepositoryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: RootSiteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NamedSiteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ChannelListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BlossomServersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileServersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PeopleListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: EphemeralChatListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FollowListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: PrivateOutboxRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: HashtagListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: GeohashListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: SearchRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: BlockedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: TrustedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayFeedsListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: TrustProviderListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ProxyRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: IndexerRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: BroadcastRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CommunityDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: EmojiPackSelectionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: EmojiPackEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ClassifiedsEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: PinListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelaySetEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: AudioTrackEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoVerticalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoHorizontalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoNormalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoShortEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayDiscoveryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayMonitorEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: StatusEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1438,66 +997,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: BadgeDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BadgeProfilesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BadgeAwardEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: NNSEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AppDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: WebBookmarkEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarDateSlotEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarTimeSlotEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarRSVPEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consumeBaseReplaceable(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1543,24 +1042,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: AppRecommendationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AppSpecificDataEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PrivateDmEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: DeletionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1993,12 +1474,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: CommentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(
|
||||
event: ChannelHideMessageEvent,
|
||||
@@ -2080,36 +1555,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: AudioHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ProfileGalleryEntryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileStorageHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FhirResourceEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TextNoteModificationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2145,30 +1590,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: HighlightEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: CodeSnippetEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ChatEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PollResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2242,30 +1663,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: SealedRumorEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GiftWrapEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LnZapPaymentRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -3141,134 +2538,134 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean =
|
||||
try {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is AppDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is AppRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationRequestEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
|
||||
is AudioHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is AudioTrackEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeAwardEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeProfilesEvent -> consume(event, relay, wasVerified)
|
||||
is BlockedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is BlossomServersEvent -> consume(event, relay, wasVerified)
|
||||
is BroadcastRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is BookmarkListEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarRSVPEvent -> consume(event, relay, wasVerified)
|
||||
is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BadgeProfilesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChannelCreateEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelListEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMetadataEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMuteUserEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is ClassifiedsEvent -> consume(event, relay, wasVerified)
|
||||
is CommentEvent -> consume(event, relay, wasVerified)
|
||||
is CommunityDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is CommunityListEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified)
|
||||
is ContactListEvent -> consume(event, relay, wasVerified)
|
||||
is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is DeletionEvent -> consume(event, relay, wasVerified)
|
||||
is DraftWrapEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EphemeralChatEvent -> consume(event, relay, wasVerified)
|
||||
is EphemeralChatListEvent -> consume(event, relay, wasVerified)
|
||||
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
|
||||
is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GenericRepostEvent -> consume(event, relay, wasVerified)
|
||||
is FhirResourceEvent -> consume(event, relay, wasVerified)
|
||||
is FileHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified)
|
||||
is FileServersEvent -> consume(event, relay, wasVerified)
|
||||
is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is FileStorageEvent -> consume(event, relay, wasVerified)
|
||||
is FileStorageHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is FollowListEvent -> consume(event, relay, wasVerified)
|
||||
is GeohashListEvent -> consume(event, relay, wasVerified)
|
||||
is GoalEvent -> consume(event, relay, wasVerified)
|
||||
is GiftWrapEvent -> consume(event, relay, wasVerified)
|
||||
is GitIssueEvent -> consume(event, relay, wasVerified)
|
||||
is GitReplyEvent -> consume(event, relay, wasVerified)
|
||||
is GitPatchEvent -> consume(event, relay, wasVerified)
|
||||
is GitRepositoryEvent -> consume(event, relay, wasVerified)
|
||||
is RootSiteEvent -> consume(event, relay, wasVerified)
|
||||
is NamedSiteEvent -> consume(event, relay, wasVerified)
|
||||
is ChessGameEvent -> consume(event, relay, wasVerified)
|
||||
is RelayFeedsListEvent -> consume(event, relay, wasVerified)
|
||||
is JesterEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consume(event, relay, wasVerified)
|
||||
is HighlightEvent -> consume(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
|
||||
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
|
||||
is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90StatusEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified)
|
||||
is LongTextNoteEvent -> consume(event, relay, wasVerified)
|
||||
is MetadataEvent -> consume(event, relay, wasVerified)
|
||||
is MuteListEvent -> consume(event, relay, wasVerified)
|
||||
is NNSEvent -> consume(event, relay, wasVerified)
|
||||
is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NipTextEvent -> consume(event, relay, wasVerified)
|
||||
is OtsEvent -> consume(event, relay, wasVerified)
|
||||
is PictureEvent -> consume(event, relay, wasVerified)
|
||||
is PrivateDmEvent -> consume(event, relay, wasVerified)
|
||||
is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is ProxyRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is PinListEvent -> consume(event, relay, wasVerified)
|
||||
is PublicMessageEvent -> consume(event, relay, wasVerified)
|
||||
is PeopleListEvent -> consume(event, relay, wasVerified)
|
||||
is RequestToVanishEvent -> consume(event, relay, wasVerified)
|
||||
is CodeSnippetEvent -> consume(event, relay, wasVerified)
|
||||
is ZapPollEvent -> consume(event, relay, wasVerified)
|
||||
is ChatEvent -> consume(event, relay, wasVerified)
|
||||
is PollEvent -> consume(event, relay, wasVerified)
|
||||
is PictureEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PollEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PollResponseEvent -> consume(event, relay, wasVerified)
|
||||
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
|
||||
is RelayMonitorEvent -> consume(event, relay, wasVerified)
|
||||
is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ReactionEvent -> consume(event, relay, wasVerified)
|
||||
is ContactCardEvent -> consume(event, relay, wasVerified)
|
||||
is RelaySetEvent -> consume(event, relay, wasVerified)
|
||||
is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ReportEvent -> consume(event, relay, wasVerified)
|
||||
is RepostEvent -> consume(event, relay, wasVerified)
|
||||
is SealedRumorEvent -> consume(event, relay, wasVerified)
|
||||
is SearchRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is StatusEvent -> consume(event, relay, wasVerified)
|
||||
is TextNoteEvent -> consume(event, relay, wasVerified)
|
||||
is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TextNoteModificationEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentCommentEvent -> consume(event, relay, wasVerified)
|
||||
is TrustedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is TrustProviderListEvent -> consume(event, relay, wasVerified)
|
||||
is VideoHorizontalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoNormalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoVerticalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoShortEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
||||
is WebBookmarkEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
||||
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
||||
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false }
|
||||
|
||||
+138
-4
@@ -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
|
||||
|
||||
+106
@@ -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,
|
||||
|
||||
+13
-5
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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 =
|
||||
|
||||
+11
-7
@@ -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)
|
||||
|
||||
+53
-9
@@ -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
|
||||
}
|
||||
|
||||
+2
-1
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -244,7 +245,7 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -97,11 +98,12 @@ fun RenderLiveChessChallenge(
|
||||
) {
|
||||
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
|
||||
val gameId = event.gameId()
|
||||
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
|
||||
|
||||
val chessViewModel: ChessViewModelNew =
|
||||
viewModel(
|
||||
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||
factory = ChessViewModelFactory(accountViewModel.account),
|
||||
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
|
||||
)
|
||||
|
||||
val isOpenChallenge = event.opponentPubkey() == null
|
||||
|
||||
+20
-19
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Android implementation of ChessEventPublisher using Jester protocol.
|
||||
@@ -59,9 +60,9 @@ class AndroidChessPublisher(
|
||||
* Uses ChessEventBroadcaster to ensure relay connections before sending.
|
||||
*/
|
||||
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
|
||||
println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
|
||||
val result = broadcaster.broadcast(event)
|
||||
println("[AndroidChessPublisher] Broadcast result: ${result.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcast result: ${result.message}" }
|
||||
return result.success
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishStart failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishStart failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishMove failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishMove failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishGameEnd failed: ${e.message}" }
|
||||
false
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
|
||||
override fun getRelayUrls(): List<String> {
|
||||
println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
|
||||
return ChessConfig.CHESS_RELAYS
|
||||
}
|
||||
|
||||
@@ -193,7 +194,7 @@ class AndroidRelayFetcher(
|
||||
* 2. Fetch moves that reference the start event via #e tag
|
||||
*/
|
||||
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
|
||||
println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId" }
|
||||
|
||||
// Filter 1: Fetch the start event by its ID
|
||||
val startEventFilter =
|
||||
@@ -223,7 +224,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}" }
|
||||
|
||||
return JesterGameEvents(
|
||||
startEvent = startEvent,
|
||||
@@ -237,30 +238,30 @@ class AndroidRelayFetcher(
|
||||
*/
|
||||
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays" }
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!" }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
|
||||
println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}")
|
||||
println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}" }
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}" }
|
||||
|
||||
val relayFilters = relays.associateWith { listOf(filter) }
|
||||
val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
|
||||
println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events" }
|
||||
|
||||
// Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events
|
||||
if (events.isEmpty()) {
|
||||
println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter..." }
|
||||
val debugFilter = ChessFilterBuilder.recentGamesFilter()
|
||||
val debugFilters = relays.associateWith { listOf(debugFilter) }
|
||||
val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000)
|
||||
println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events" }
|
||||
debugEvents.take(5).forEach { e ->
|
||||
println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +287,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events" }
|
||||
return challenges
|
||||
}
|
||||
|
||||
@@ -356,7 +357,7 @@ class AndroidRelayFetcher(
|
||||
* Uses one-shot fetch for reliability.
|
||||
*/
|
||||
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
|
||||
println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: fetching from relays" }
|
||||
|
||||
// Fetch events authored by user AND events tagging user
|
||||
val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
|
||||
@@ -386,7 +387,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events" }
|
||||
return gameIds
|
||||
}
|
||||
}
|
||||
|
||||
+56
-4
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+112
-7
@@ -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))
|
||||
|
||||
+2
-1
@@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
*/
|
||||
class ChessViewModelFactory(
|
||||
private val account: Account,
|
||||
private val application: android.app.Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) {
|
||||
return ChessViewModelNew(account) as T
|
||||
return ChessViewModelNew(account, application) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
+33
-1
@@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Query state for chess subscription
|
||||
@@ -39,16 +44,43 @@ data class ChessQueryState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Sub-assembler that creates the actual relay filters
|
||||
* Sub-assembler that creates the actual relay filters.
|
||||
* Intercepts incoming chess events from relays and forwards them to the ViewModel.
|
||||
*/
|
||||
class ChessFeedFilterSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ChessQueryState>,
|
||||
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
||||
var onChessEvent: ((Event) -> Unit)? = null
|
||||
|
||||
override fun updateFilter(
|
||||
key: ChessQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
||||
|
||||
override fun id(key: ChessQueryState): String = key.userPubkey
|
||||
|
||||
override fun newSub(key: ChessQueryState): Subscription =
|
||||
requestNewSubscription(
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (isLive) {
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
onChessEvent?.invoke(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+8
-4
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
@@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
class ChessFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ChessQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ChessFeedFilterSubAssembler(client, ::allKeys),
|
||||
)
|
||||
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
|
||||
|
||||
val group = listOf(subAssembler)
|
||||
|
||||
fun setOnChessEvent(callback: ((Event) -> Unit)?) {
|
||||
subAssembler.onChessEvent = callback
|
||||
}
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
}
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+4
-8
@@ -262,8 +262,7 @@ class CardFeedContentState(
|
||||
val sortedList =
|
||||
singleList
|
||||
.get(string)
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed()
|
||||
?.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
|
||||
sortedList?.chunked(30)?.map { chunk ->
|
||||
MultiSetCard(
|
||||
@@ -293,8 +292,7 @@ class CardFeedContentState(
|
||||
ZapUserSetCard(
|
||||
user.key,
|
||||
zaps
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<CombinedZap> { it.createdAt() }.thenBy { it.idHex() })
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
@@ -318,8 +316,7 @@ class CardFeedContentState(
|
||||
}
|
||||
|
||||
return (multiCards + textNoteCards + userZaps)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Card>) {
|
||||
@@ -383,8 +380,7 @@ class CardFeedContentState(
|
||||
val updatedCards =
|
||||
(oldNotesState.feed.value.list + newCards)
|
||||
.distinctBy { it.id() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
|
||||
+1
-1
@@ -80,6 +80,6 @@ class OpenPollsState(
|
||||
false
|
||||
}
|
||||
}
|
||||
}.sortedByDescending { it.createdAt() }
|
||||
}.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)) &&
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() {
|
||||
sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed",
|
||||
)
|
||||
|
||||
LocalCache.consume(releaseNotes, null, true)
|
||||
LocalCache.consumeRegularEvent(releaseNotes, null, true)
|
||||
}
|
||||
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -71,6 +73,9 @@ class TorManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
}.catch { e ->
|
||||
Log.e("TorManager") { "Tor service error: ${e.message}" }
|
||||
emit(TorServiceStatus.Off)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
|
||||
@@ -54,18 +54,23 @@ class TorService(
|
||||
service: IBinder,
|
||||
) {
|
||||
launch(Dispatchers.IO) {
|
||||
// moved torService to a local variable, since we only need it once
|
||||
val torService = (service as LocalBinder).service
|
||||
try {
|
||||
// moved torService to a local variable, since we only need it once
|
||||
val torService = (service as LocalBinder).service
|
||||
|
||||
while (torService.socksPort < 0) {
|
||||
delay(SOCKS_PORT_POLL_INTERVAL_MS)
|
||||
while (torService.socksPort < 0) {
|
||||
delay(SOCKS_PORT_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
val active = TorServiceStatus.Active(torService.socksPort)
|
||||
active.torControlConnection = torService.torControlConnection
|
||||
|
||||
trySend(active)
|
||||
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
|
||||
} catch (e: Exception) {
|
||||
Log.e("TorService") { "Tor service connection failed: ${e.message}" }
|
||||
trySend(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
val active = TorServiceStatus.Active(torService.socksPort)
|
||||
active.torControlConnection = torService.torControlConnection
|
||||
|
||||
trySend(active)
|
||||
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,11 +80,16 @@ class TorService(
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(
|
||||
currentIntent,
|
||||
serviceConnection,
|
||||
BIND_AUTO_CREATE,
|
||||
)
|
||||
try {
|
||||
context.bindService(
|
||||
currentIntent,
|
||||
serviceConnection,
|
||||
BIND_AUTO_CREATE,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" }
|
||||
trySend(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
Log.d("TorService", "Stopping Tor Service")
|
||||
@@ -88,8 +98,10 @@ class TorService(
|
||||
} catch (e: Exception) {
|
||||
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
|
||||
}
|
||||
launch {
|
||||
try {
|
||||
context.stopService(currentIntent)
|
||||
} catch (e: Exception) {
|
||||
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
|
||||
}
|
||||
trySend(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
@@ -1622,6 +1622,7 @@
|
||||
<string name="namecoin_test_connection">Test połączenia</string>
|
||||
<string name="namecoin_testing">Testowanie serwerów…</string>
|
||||
<string name="namecoin_test_success">Podłączony</string>
|
||||
<string name="namecoin_test_failed">Test nieudany</string>
|
||||
<string name="namecoin_test_results">Wyniki testu</string>
|
||||
<string name="namecoin_diagnostics">Diagnostyka</string>
|
||||
<string name="namecoin_last_test">Ostatni test</string>
|
||||
|
||||
@@ -655,6 +655,11 @@
|
||||
<string name="app_notification_mark_read_label">标记为已读</string>
|
||||
<string name="app_notification_dms_summary">新信息</string>
|
||||
<string name="app_notification_zaps_summary">新打闪</string>
|
||||
<string name="app_notification_chess_channel_name">国际象棋</string>
|
||||
<string name="app_notification_chess_channel_description">提醒您有关国际象棋游戏的事件</string>
|
||||
<string name="app_notification_chess_challenge_accepted">%1$s 接受了您的国际象棋挑战</string>
|
||||
<string name="app_notification_chess_your_turn">%1$s 已移动 —— 轮到您了</string>
|
||||
<string name="app_notification_chess_summary">国际象棋更新</string>
|
||||
<string name="reply_notify">通知:</string>
|
||||
<string name="channel_list_join_conversation">加入对话</string>
|
||||
<string name="channel_list_user_or_group_id">用户或群组 ID</string>
|
||||
|
||||
@@ -751,6 +751,19 @@
|
||||
<string name="app_notification_dms_summary">New messages</string>
|
||||
<string name="app_notification_zaps_summary">New zaps</string>
|
||||
|
||||
<string name="app_notification_reactions_channel_id" translatable="false">ReactionsID</string>
|
||||
<string name="app_notification_reactions_channel_name">Reactions</string>
|
||||
<string name="app_notification_reactions_channel_description">Notifies you when somebody reacts to your post</string>
|
||||
<string name="app_notification_reactions_channel_message">%1$s reacted to your post</string>
|
||||
<string name="app_notification_reactions_channel_message_for">for %1$s</string>
|
||||
<string name="app_notification_reactions_summary">New reactions</string>
|
||||
<string name="app_notification_chess_channel_id" translatable="false">ChessID</string>
|
||||
<string name="app_notification_chess_channel_name">Chess</string>
|
||||
<string name="app_notification_chess_channel_description">Notifies you about chess game events</string>
|
||||
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
|
||||
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
|
||||
<string name="app_notification_chess_summary">Chess updates</string>
|
||||
|
||||
<string name="reply_notify">Notify: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Join Conversation</string>
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
actual class ChessDismissedGamesStorage private actual constructor() {
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
actual companion object {
|
||||
private const val PREFS_NAME = "chess_dismissed_games"
|
||||
|
||||
private fun prefsKey(userPubkey: String) = "dismissed_$userPubkey"
|
||||
|
||||
actual fun create(context: Any?): ChessDismissedGamesStorage {
|
||||
val storage = ChessDismissedGamesStorage()
|
||||
val ctx =
|
||||
context as? Context
|
||||
?: throw IllegalArgumentException("Android context required")
|
||||
storage.prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return storage
|
||||
}
|
||||
}
|
||||
|
||||
// getStringSet returns a live reference to the internal set — must copy defensively
|
||||
actual fun load(userPubkey: String): Set<String> = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet()
|
||||
|
||||
actual fun save(
|
||||
userPubkey: String,
|
||||
ids: Set<String>,
|
||||
) {
|
||||
prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply()
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,12 @@ fun ChessBoard(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
rankLabel =
|
||||
if (showCoordinates && file == 0) {
|
||||
(rank + 1).toString()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +101,7 @@ private fun ChessSquare(
|
||||
isLight: Boolean,
|
||||
size: Dp,
|
||||
coordinate: String?,
|
||||
rankLabel: String? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -114,6 +121,20 @@ private fun ChessSquare(
|
||||
)
|
||||
}
|
||||
|
||||
// Show rank label (1-8) on left file
|
||||
rankLabel?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Show file coordinate (a-h) on bottom rank
|
||||
coordinate?.let {
|
||||
Text(
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
/**
|
||||
* Persists dismissed chess game IDs locally per user.
|
||||
* Uses expect/actual for platform-specific storage.
|
||||
*/
|
||||
expect class ChessDismissedGamesStorage private constructor() {
|
||||
companion object {
|
||||
fun create(context: Any? = null): ChessDismissedGamesStorage
|
||||
}
|
||||
|
||||
fun load(userPubkey: String): Set<String>
|
||||
|
||||
fun save(
|
||||
userPubkey: String,
|
||||
ids: Set<String>,
|
||||
)
|
||||
}
|
||||
+22
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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)
|
||||
|
||||
+15
-4
@@ -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()
|
||||
|
||||
|
||||
+17
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+182
-11
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst.commons.chess
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameStatus
|
||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -122,9 +125,24 @@ class ChessLobbyLogic(
|
||||
private val metadataProvider: IUserMetadataProvider,
|
||||
private val scope: CoroutineScope,
|
||||
pollingConfig: ChessPollingConfig = ChessPollingDefaults.android,
|
||||
private val dismissedStorage: ChessDismissedGamesStorage? = null,
|
||||
) {
|
||||
val state = ChessLobbyState(userPubkey, scope)
|
||||
|
||||
private val dismissedGameIds: MutableSet<String> =
|
||||
java.util.Collections.synchronizedSet(
|
||||
dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
|
||||
)
|
||||
|
||||
// Track when games were last loaded to prevent duplicate fetches
|
||||
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
|
||||
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
|
||||
// Dedup incoming events (same event delivered by multiple relays)
|
||||
// Bounded LRU: evict oldest when exceeding capacity
|
||||
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
|
||||
private val seenEventIdsMax = 500
|
||||
|
||||
private val pollingDelegate =
|
||||
ChessPollingDelegate(
|
||||
config = pollingConfig,
|
||||
@@ -185,6 +203,21 @@ class ChessLobbyLogic(
|
||||
* Called by platform subscription callbacks for real-time updates.
|
||||
*/
|
||||
fun handleIncomingEvent(event: JesterEvent) {
|
||||
// Skip non-chess events (kind 30 but not start or move)
|
||||
if (!event.isStartEvent() && !event.isMoveEvent()) return
|
||||
|
||||
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
|
||||
synchronized(seenEventIds) {
|
||||
if (!seenEventIds.add(event.id)) return
|
||||
if (seenEventIds.size > seenEventIdsMax) {
|
||||
seenEventIds.iterator().let {
|
||||
it.next()
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" }
|
||||
when {
|
||||
event.isStartEvent() -> handleStartEvent(event)
|
||||
event.isMoveEvent() -> handleMoveEvent(event)
|
||||
@@ -195,6 +228,8 @@ class ChessLobbyLogic(
|
||||
val startEventId = event.id
|
||||
val challengerColor = event.playerColor() ?: Color.WHITE
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] handleStartEvent: game=${startEventId.take(8)}, challenger=${event.pubKey.take(8)}, color=$challengerColor, opponent=${event.opponentPubkey()?.take(8)}" }
|
||||
|
||||
val challenge =
|
||||
ChessChallenge(
|
||||
eventId = event.id,
|
||||
@@ -210,12 +245,26 @@ class ChessLobbyLogic(
|
||||
}
|
||||
|
||||
private fun handleMoveEvent(event: JesterEvent) {
|
||||
val startEventId = event.startEventId() ?: return
|
||||
val san = event.move() ?: return
|
||||
val fen = event.fen() ?: return
|
||||
val startEventId =
|
||||
event.startEventId() ?: run {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no startEventId for event ${event.id.take(8)}" }
|
||||
return
|
||||
}
|
||||
val san =
|
||||
event.move() ?: run {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no move in event ${event.id.take(8)}" }
|
||||
return
|
||||
}
|
||||
val fen =
|
||||
event.fen() ?: run {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no FEN in event ${event.id.take(8)}" }
|
||||
return
|
||||
}
|
||||
val history = event.history()
|
||||
val moveNumber = history.size
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game=${startEventId.take(8)}, move=$san, moveNumber=$moveNumber, from=${event.pubKey.take(8)}, result=${event.result()}" }
|
||||
|
||||
// Check if this is our game (we're either the author or tagged as opponent)
|
||||
val opponentFromTag = event.opponentPubkey()
|
||||
val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey
|
||||
@@ -225,23 +274,28 @@ class ChessLobbyLogic(
|
||||
// If this is our game but we haven't loaded it yet, load it now
|
||||
// This happens when someone accepts our challenge (makes first move)
|
||||
if (gameState == null && isOurGame) {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded but is ours - calling handleGameAccepted" }
|
||||
handleGameAccepted(startEventId)
|
||||
return // handleGameAccepted will load the game and poll for events
|
||||
}
|
||||
|
||||
if (gameState == null) return
|
||||
if (gameState == null) {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded and not ours - ignoring" }
|
||||
return
|
||||
}
|
||||
|
||||
// Check for game end
|
||||
val result = event.result()
|
||||
if (result != null) {
|
||||
val gameResult =
|
||||
when (result) {
|
||||
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
|
||||
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
|
||||
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
|
||||
"1-0" -> GameResult.WHITE_WINS
|
||||
"0-1" -> GameResult.BLACK_WINS
|
||||
"1/2-1/2" -> GameResult.DRAW
|
||||
else -> null
|
||||
}
|
||||
if (gameResult != null) {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} ENDED: $result, termination=${event.termination()}" }
|
||||
gameState.markAsFinished(gameResult)
|
||||
state.moveToCompleted(startEventId, result, event.termination())
|
||||
pollingDelegate.removeGameId(startEventId)
|
||||
@@ -251,9 +305,12 @@ class ChessLobbyLogic(
|
||||
|
||||
// Only apply opponent moves optimistically
|
||||
if (event.pubKey != userPubkey) {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: applying opponent move $san (move #$moveNumber) to game ${startEventId.take(8)}" }
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
// Update head event ID for move linking
|
||||
gameState.updateHeadEventId(event.id)
|
||||
} else {
|
||||
Log.d("chessdebug") { "[Lobby] handleMoveEvent: skipping own move $san for game ${startEventId.take(8)}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +323,7 @@ class ChessLobbyLogic(
|
||||
playerColor: Color = Color.WHITE,
|
||||
timeControl: String? = null, // Not supported in Jester, kept for API compatibility
|
||||
) {
|
||||
Log.d("chessdebug") { "[Lobby] createChallenge: opponent=${opponentPubkey?.take(8)}, color=$playerColor" }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Broadcasting(
|
||||
@@ -278,6 +336,7 @@ class ChessLobbyLogic(
|
||||
val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) }
|
||||
|
||||
if (startEventId != null) {
|
||||
Log.d("chessdebug") { "[Lobby] createChallenge SUCCESS: startEventId=${startEventId.take(8)}" }
|
||||
// Add challenge to local state - shows in "Your Challenges" section
|
||||
val challenge =
|
||||
ChessChallenge(
|
||||
@@ -300,6 +359,7 @@ class ChessLobbyLogic(
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
state.setError(null)
|
||||
} else {
|
||||
Log.d("chessdebug") { "[Lobby] createChallenge FAILED: publish returned null" }
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Failed("Challenge", "Failed to publish"),
|
||||
)
|
||||
@@ -315,6 +375,7 @@ class ChessLobbyLogic(
|
||||
* In Jester protocol, acceptance is implicit - we just track the game locally.
|
||||
*/
|
||||
fun acceptChallenge(challenge: ChessChallenge) {
|
||||
Log.d("chessdebug") { "[Lobby] acceptChallenge: game=${challenge.gameId.take(8)}, challenger=${challenge.challengerPubkey.take(8)}, color=${challenge.challengerColor.opposite()}" }
|
||||
// Mark as accepted SYNCHRONOUSLY before launching coroutine
|
||||
// This prevents race where navigation happens before coroutine runs,
|
||||
// which would cause loadGame() to incorrectly mark as spectator
|
||||
@@ -376,14 +437,27 @@ class ChessLobbyLogic(
|
||||
* When we detect our challenge was accepted (opponent made first move), load game from relays.
|
||||
*/
|
||||
fun handleGameAccepted(startEventId: String) {
|
||||
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
|
||||
val lastLoaded = recentlyLoadedGames[startEventId]
|
||||
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
||||
return
|
||||
}
|
||||
// Mark immediately to prevent concurrent launches
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||
|
||||
val events = fetcher.fetchGameEvents(startEventId)
|
||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted: fetched ${events.moves.size} moves for game ${startEventId.take(8)}" }
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
|
||||
state.addActiveGame(startEventId, result.liveState)
|
||||
pollingDelegate.addGameId(startEventId)
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
@@ -391,6 +465,7 @@ class ChessLobbyLogic(
|
||||
}
|
||||
|
||||
is LoadGameResult.Error -> {
|
||||
Log.d("chessdebug") { "[Lobby] handleGameAccepted FAILED: game ${startEventId.take(8)}, error=${result.message}" }
|
||||
state.setError("Failed to load game: ${result.message}")
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
}
|
||||
@@ -407,9 +482,15 @@ class ChessLobbyLogic(
|
||||
from: String,
|
||||
to: String,
|
||||
) {
|
||||
val gameState = state.getGameState(startEventId) ?: return
|
||||
Log.d("chessdebug") { "[Lobby] publishMove: game=${startEventId.take(8)}, from=$from, to=$to" }
|
||||
val gameState =
|
||||
state.getGameState(startEventId) ?: run {
|
||||
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - no game state for ${startEventId.take(8)}" }
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isSpectating(startEventId)) {
|
||||
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - spectating game ${startEventId.take(8)}" }
|
||||
state.setError("Cannot move while spectating")
|
||||
return
|
||||
}
|
||||
@@ -417,7 +498,13 @@ class ChessLobbyLogic(
|
||||
// Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN)
|
||||
val (actualTo, promotion) = parsePromotionFromTo(to)
|
||||
|
||||
val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return
|
||||
val moveResult =
|
||||
gameState.makeMove(from, actualTo, promotion) ?: run {
|
||||
Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - makeMove returned null for $from->$actualTo in game ${startEventId.take(8)}" }
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] publishMove: move validated: san=${moveResult.san}, fen=${moveResult.fen.take(30)}, history=${moveResult.history.size} moves, headEvent=${moveResult.headEventId.take(8)}" }
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(
|
||||
@@ -431,6 +518,7 @@ class ChessLobbyLogic(
|
||||
val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) }
|
||||
|
||||
if (newEventId != null) {
|
||||
Log.d("chessdebug") { "[Lobby] publishMove SUCCESS: newEventId=${newEventId.take(8)} for game ${startEventId.take(8)}" }
|
||||
// Update head event ID for next move linking
|
||||
gameState.updateHeadEventId(newEventId)
|
||||
|
||||
@@ -449,6 +537,7 @@ class ChessLobbyLogic(
|
||||
)
|
||||
state.setError(null)
|
||||
} else {
|
||||
Log.d("chessdebug") { "[Lobby] publishMove FAILED: reverting move ${moveResult.san} for game ${startEventId.take(8)}" }
|
||||
// Revert the move since publishing failed
|
||||
gameState.undoLastMove()
|
||||
|
||||
@@ -461,6 +550,7 @@ class ChessLobbyLogic(
|
||||
}
|
||||
|
||||
fun resign(startEventId: String) {
|
||||
Log.d("chessdebug") { "[Lobby] resign: game=${startEventId.take(8)}" }
|
||||
val gameState = state.getGameState(startEventId) ?: return
|
||||
|
||||
if (state.isSpectating(startEventId)) {
|
||||
@@ -503,7 +593,13 @@ class ChessLobbyLogic(
|
||||
// Spectator mode
|
||||
// ========================================
|
||||
|
||||
fun stopSpectating(gameId: String) {
|
||||
state.removeSpectatingGame(gameId)
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
}
|
||||
|
||||
fun loadGameAsSpectator(startEventId: String) {
|
||||
Log.d("chessdebug") { "[Lobby] loadGameAsSpectator: game=${startEventId.take(8)}" }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||
|
||||
@@ -512,6 +608,7 @@ class ChessLobbyLogic(
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
state.addSpectatingGame(startEventId, result.liveState)
|
||||
pollingDelegate.addGameId(startEventId)
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
@@ -529,9 +626,11 @@ class ChessLobbyLogic(
|
||||
}
|
||||
|
||||
fun loadGame(startEventId: String) {
|
||||
Log.d("chessdebug") { "[Lobby] loadGame: game=${startEventId.take(8)}" }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
// Don't load if game already exists or was accepted (acceptChallenge will handle it)
|
||||
if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) {
|
||||
Log.d("chessdebug") { "[Lobby] loadGame: skipping - already exists or was accepted for ${startEventId.take(8)}" }
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -542,6 +641,7 @@ class ChessLobbyLogic(
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
if (result.liveState.isSpectator) {
|
||||
state.addSpectatingGame(startEventId, result.liveState)
|
||||
} else {
|
||||
@@ -577,16 +677,36 @@ class ChessLobbyLogic(
|
||||
}
|
||||
|
||||
private suspend fun refreshGame(startEventId: String) {
|
||||
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
|
||||
val lastLoaded = recentlyLoadedGames[startEventId]
|
||||
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
|
||||
return
|
||||
}
|
||||
// Mark immediately to prevent concurrent fetches for the same game
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
|
||||
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
|
||||
val events = fetcher.fetchGameEvents(startEventId)
|
||||
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
state.replaceGameState(startEventId, result.liveState)
|
||||
// Check status FIRST to avoid briefly emitting a finished game through activeGames
|
||||
val gameStatus = result.liveState.gameStatus.value
|
||||
if (gameStatus is GameStatus.Finished) {
|
||||
Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${gameStatus.result}), moving to completed" }
|
||||
state.moveToCompleted(startEventId, gameStatus.result.notation, null)
|
||||
pollingDelegate.removeGameId(startEventId)
|
||||
} else {
|
||||
Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} updated, moves=${result.liveState.moveHistory.value.size}, isPlayerTurn=${result.liveState.isPlayerTurn()}" }
|
||||
state.replaceGameState(startEventId, result.liveState)
|
||||
}
|
||||
}
|
||||
|
||||
is LoadGameResult.Error -> {
|
||||
Log.d("chessdebug") { "[Lobby] refreshGame: ERROR for game ${startEventId.take(8)}: ${result.message}" }
|
||||
// Don't overwrite error for periodic refresh failures
|
||||
}
|
||||
}
|
||||
@@ -747,13 +867,27 @@ class ChessLobbyLogic(
|
||||
|
||||
val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds
|
||||
|
||||
// Also check completed games to avoid re-adding them
|
||||
val completedGameIds =
|
||||
state.completedGames.value
|
||||
.map { it.gameId }
|
||||
.toSet()
|
||||
|
||||
for (startEventId in newGameIds) {
|
||||
if (startEventId in completedGameIds) continue
|
||||
if (startEventId in dismissedGameIds) continue
|
||||
|
||||
val events = fetcher.fetchGameEvents(startEventId)
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
if (!result.liveState.isSpectator) {
|
||||
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||
// If the discovered game is already finished, send it straight to completed
|
||||
val gameStatus = result.liveState.gameStatus.value
|
||||
if (gameStatus is GameStatus.Finished) {
|
||||
state.moveToCompleted(startEventId, gameStatus.result.notation, null, liveState = result.liveState)
|
||||
} else if (!result.liveState.isSpectator) {
|
||||
state.addActiveGame(startEventId, result.liveState)
|
||||
pollingDelegate.addGameId(startEventId)
|
||||
} else {
|
||||
@@ -815,6 +949,43 @@ class ChessLobbyLogic(
|
||||
return null
|
||||
}
|
||||
|
||||
fun dismissCompletedGame(gameId: String) {
|
||||
state.removeCompletedGame(gameId)
|
||||
dismissedGameIds.add(gameId)
|
||||
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
|
||||
}
|
||||
|
||||
fun dismissAllCompletedGames() {
|
||||
val allIds = state.completedGames.value.map { it.gameId }
|
||||
state.clearCompletedGames()
|
||||
dismissedGameIds.addAll(allIds)
|
||||
dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a finished game from the active/spectating list and move it to completed.
|
||||
* Called when the user clicks "Continue" on the game end overlay, or automatically
|
||||
* when a finished game is detected during polling refresh.
|
||||
*/
|
||||
fun dismissGame(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
val status = gameState.gameStatus.value
|
||||
if (status is GameStatus.Finished) {
|
||||
state.moveToCompleted(gameId, status.result.notation, null)
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a game from all lobby lists (active, spectating) and stop polling.
|
||||
* Used when a game fails to load ("Game not found") so the user can dismiss the stale entry.
|
||||
*/
|
||||
fun removeGame(gameId: String) {
|
||||
state.removeActiveGame(gameId)
|
||||
state.removeSpectatingGame(gameId)
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
state.setError(null)
|
||||
}
|
||||
|
||||
+57
-48
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameStatus
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -296,7 +297,8 @@ class ChessLobbyState(
|
||||
}
|
||||
|
||||
fun updatePublicGames(games: List<PublicGame>) {
|
||||
_publicGames.value = games
|
||||
// Filter out games where the user is a participant
|
||||
_publicGames.value = games.filter { it.whitePubkey != userPubkey && it.blackPubkey != userPubkey }
|
||||
}
|
||||
|
||||
fun addActiveGame(
|
||||
@@ -392,65 +394,62 @@ class ChessLobbyState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a game from active/spectating to completed.
|
||||
* Display names are optional; UI can look them up later if needed.
|
||||
* Move a game to completed. Looks up the game from active/spectating maps,
|
||||
* or uses the provided [liveState] if the game isn't in either map yet
|
||||
* (e.g. a newly discovered game that is already finished).
|
||||
*/
|
||||
fun moveToCompleted(
|
||||
gameId: String,
|
||||
result: String,
|
||||
termination: String?,
|
||||
liveState: LiveChessGameState? = null,
|
||||
whiteDisplayName: String? = null,
|
||||
blackDisplayName: String? = null,
|
||||
) {
|
||||
val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
|
||||
existingState?.let { gameState ->
|
||||
// Derive white/black pubkeys from player color
|
||||
val whitePubkey =
|
||||
if (gameState.playerColor == Color.WHITE) {
|
||||
gameState.playerPubkey
|
||||
} else {
|
||||
gameState.opponentPubkey
|
||||
}
|
||||
val blackPubkey =
|
||||
if (gameState.playerColor == Color.BLACK) {
|
||||
gameState.playerPubkey
|
||||
} else {
|
||||
gameState.opponentPubkey
|
||||
}
|
||||
val gameState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] ?: liveState ?: return
|
||||
|
||||
val completed =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
whitePubkey = whitePubkey,
|
||||
whiteDisplayName = whiteDisplayName,
|
||||
blackPubkey = blackPubkey,
|
||||
blackDisplayName = blackDisplayName,
|
||||
result = result,
|
||||
termination = termination,
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
completedAt =
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
)
|
||||
val whitePubkey =
|
||||
if (gameState.playerColor == Color.WHITE) gameState.playerPubkey else gameState.opponentPubkey
|
||||
val blackPubkey =
|
||||
if (gameState.playerColor == Color.BLACK) gameState.playerPubkey else gameState.opponentPubkey
|
||||
|
||||
_completedGames.update { current ->
|
||||
// Avoid duplicates
|
||||
if (current.any { it.gameId == gameId }) {
|
||||
current
|
||||
} else {
|
||||
listOf(completed) + current
|
||||
}
|
||||
}
|
||||
val completed =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
whitePubkey = whitePubkey,
|
||||
whiteDisplayName = whiteDisplayName,
|
||||
blackPubkey = blackPubkey,
|
||||
blackDisplayName = blackDisplayName,
|
||||
result = result,
|
||||
termination = termination,
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
completedAt =
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
)
|
||||
|
||||
// Remove from active/spectating
|
||||
_activeGames.update { it - gameId }
|
||||
_spectatingGames.update { it - gameId }
|
||||
|
||||
// Clear selection if this game was selected
|
||||
if (_selectedGameId.value == gameId) {
|
||||
_selectedGameId.value = null
|
||||
}
|
||||
_completedGames.update { current ->
|
||||
if (current.any { it.gameId == gameId }) current else listOf(completed) + current
|
||||
}
|
||||
|
||||
// Remove from active/spectating
|
||||
_activeGames.update { it - gameId }
|
||||
_spectatingGames.update { it - gameId }
|
||||
|
||||
// Clear selection if this game was selected
|
||||
if (_selectedGameId.value == gameId) {
|
||||
_selectedGameId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCompletedGame(gameId: String) {
|
||||
_completedGames.update { current ->
|
||||
current.filter { it.gameId != gameId }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCompletedGames() {
|
||||
_completedGames.value = emptyList()
|
||||
}
|
||||
|
||||
fun addSpectatingGame(
|
||||
@@ -463,6 +462,16 @@ class ChessLobbyState(
|
||||
_activeGames.update { it + (gameId to state) }
|
||||
return
|
||||
}
|
||||
// Don't add own games to spectating list
|
||||
// Note: LiveChessGameState.playerPubkey is always viewerPubkey regardless of role,
|
||||
// so we check isSpectator instead to determine if the user is actually a participant
|
||||
if (!state.isSpectator) {
|
||||
return
|
||||
}
|
||||
// Don't add finished games to spectating list
|
||||
if (state.gameStatus.value is GameStatus.Finished) {
|
||||
return
|
||||
}
|
||||
_spectatingGames.update { it + (gameId to state) }
|
||||
}
|
||||
|
||||
|
||||
+257
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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(
|
||||
|
||||
+54
-18
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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()
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ object SearchResultFilter {
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return result.sortedByDescending { it.createdAt }
|
||||
return result.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" }
|
||||
|
||||
+3
-3
@@ -33,16 +33,16 @@ object SearchResultSorter {
|
||||
): List<Event> =
|
||||
when (order) {
|
||||
SearchSortOrder.NEWEST -> {
|
||||
events.sortedByDescending { it.createdAt }
|
||||
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
SearchSortOrder.OLDEST -> {
|
||||
events.sortedBy { it.createdAt }
|
||||
events.sortedWith(compareBy<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
SearchSortOrder.RELEVANCE -> {
|
||||
if (searchText.isBlank()) {
|
||||
events.sortedByDescending { it.createdAt }
|
||||
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
} else {
|
||||
events.sortedByDescending { scoreEvent(it, searchText) }
|
||||
}
|
||||
|
||||
+1
-1
@@ -75,4 +75,4 @@ sealed class CardFeedState {
|
||||
*/
|
||||
val DefaultCardComparator: Comparator<Card> =
|
||||
compareByDescending<Card> { it.createdAt() }
|
||||
.thenByDescending { it.id() }
|
||||
.thenBy { it.id() }
|
||||
|
||||
+8
@@ -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(
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
actual class ChessDismissedGamesStorage private actual constructor() {
|
||||
private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java)
|
||||
|
||||
actual companion object {
|
||||
private const val NODE_PREFIX = "chess_dismissed_"
|
||||
private const val DELIMITER = ","
|
||||
|
||||
actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
|
||||
}
|
||||
|
||||
actual fun load(userPubkey: String): Set<String> {
|
||||
val raw = prefs.get("$NODE_PREFIX$userPubkey", "")
|
||||
if (raw.isEmpty()) return emptySet()
|
||||
return raw.split(DELIMITER).toSet()
|
||||
}
|
||||
|
||||
actual fun save(
|
||||
userPubkey: String,
|
||||
ids: Set<String>,
|
||||
) {
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove("$NODE_PREFIX$userPubkey")
|
||||
} else {
|
||||
prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER))
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
-49
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Desktop implementation of ChessEventPublisher using Jester protocol.
|
||||
@@ -63,9 +64,9 @@ class DesktopChessPublisher(
|
||||
* Uses ChessEventBroadcaster to ensure relay connections before sending.
|
||||
*/
|
||||
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
|
||||
println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
|
||||
Log.d("chessdebug") { "[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
|
||||
val result = broadcaster.broadcast(event)
|
||||
println("[DesktopChessPublisher] Broadcast result: ${result.message}")
|
||||
Log.d("chessdebug") { "[DesktopChessPublisher] Broadcast result: ${result.message}" }
|
||||
return result.success
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ class DesktopChessPublisher(
|
||||
val success = broadcastToChessRelays(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishStart failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[DesktopChessPublisher] publishStart failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ class DesktopChessPublisher(
|
||||
val success = broadcastToChessRelays(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishMove failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[DesktopChessPublisher] publishMove failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ class DesktopChessPublisher(
|
||||
val signedEvent = account.signer.sign(template)
|
||||
broadcastToChessRelays(signedEvent)
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[DesktopChessPublisher] publishGameEnd failed: ${e.message}" }
|
||||
false
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ class DesktopRelayFetcher(
|
||||
private fun chessRelayUrls(): List<NormalizedRelayUrl> = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }
|
||||
|
||||
override fun getRelayUrls(): List<String> {
|
||||
println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
|
||||
return ChessConfig.CHESS_RELAYS
|
||||
}
|
||||
|
||||
@@ -182,11 +183,11 @@ class DesktopRelayFetcher(
|
||||
* 2. Fetch moves that reference the start event via #e tag
|
||||
*/
|
||||
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
|
||||
println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId" }
|
||||
|
||||
// Query cache first (populated by subscription)
|
||||
val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId)
|
||||
println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}" }
|
||||
|
||||
// Also do relay query to catch any new events and populate cache
|
||||
// Filter 1: Fetch the start event by its ID
|
||||
@@ -220,7 +221,7 @@ class DesktopRelayFetcher(
|
||||
// Merge: prefer cache start event, merge all moves
|
||||
val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id }
|
||||
|
||||
println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}" }
|
||||
|
||||
return JesterGameEvents(
|
||||
startEvent = cachedEvents.startEvent ?: relayStartEvent,
|
||||
@@ -233,7 +234,7 @@ class DesktopRelayFetcher(
|
||||
*/
|
||||
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays" }
|
||||
|
||||
// Query cache for start events that are:
|
||||
// 1. Open challenges (no specific opponent)
|
||||
@@ -246,7 +247,7 @@ class DesktopRelayFetcher(
|
||||
event.pubKey == userPubkey
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache" }
|
||||
|
||||
// Also do relay query to catch any new challenges
|
||||
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
|
||||
@@ -264,7 +265,7 @@ class DesktopRelayFetcher(
|
||||
|
||||
// Merge and deduplicate
|
||||
val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id }
|
||||
println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge" }
|
||||
return allStartEvents
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ class DesktopRelayFetcher(
|
||||
*/
|
||||
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays" }
|
||||
|
||||
val gameIds = mutableSetOf<String>()
|
||||
|
||||
@@ -291,7 +292,7 @@ class DesktopRelayFetcher(
|
||||
event.startEventId()?.let { gameIds.add(it) }
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache" }
|
||||
|
||||
// Also do relay query to catch any new game IDs
|
||||
val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
|
||||
@@ -314,7 +315,7 @@ class DesktopRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge")
|
||||
Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge" }
|
||||
return gameIds
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.chess
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
|
||||
@@ -59,6 +60,7 @@ class DesktopChessViewModelNew(
|
||||
private val publisher = DesktopChessPublisher(account, relayManager)
|
||||
private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex)
|
||||
private val metadataProvider = DesktopMetadataProvider(userMetadataCache)
|
||||
private val dismissedStorage = ChessDismissedGamesStorage.create()
|
||||
|
||||
// Shared business logic (creates its own ChessLobbyState internally)
|
||||
private val logic =
|
||||
@@ -69,6 +71,7 @@ class DesktopChessViewModelNew(
|
||||
metadataProvider = metadataProvider,
|
||||
scope = scope,
|
||||
pollingConfig = ChessPollingDefaults.desktop,
|
||||
dismissedStorage = dismissedStorage,
|
||||
)
|
||||
|
||||
// ============================================
|
||||
@@ -162,6 +165,12 @@ class DesktopChessViewModelNew(
|
||||
|
||||
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
|
||||
|
||||
fun dismissGame(gameId: String) = logic.dismissGame(gameId)
|
||||
|
||||
fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId)
|
||||
|
||||
fun dismissAllCompletedGames() = logic.dismissAllCompletedGames()
|
||||
|
||||
// ============================================
|
||||
// Spectator operations
|
||||
// ============================================
|
||||
@@ -170,7 +179,9 @@ class DesktopChessViewModelNew(
|
||||
|
||||
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
|
||||
|
||||
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
|
||||
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
|
||||
|
||||
fun removeGame(gameId: String) = logic.removeGame(gameId)
|
||||
|
||||
// ============================================
|
||||
// Utility
|
||||
|
||||
@@ -196,7 +196,7 @@ fun ReadsScreen(
|
||||
remember {
|
||||
EventCollectionState<LongTextNoteEvent>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt },
|
||||
sortComparator = compareByDescending<LongTextNoteEvent> { it.publishedAt() ?: it.createdAt }.thenBy { it.id },
|
||||
maxSize = 100,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
+2
-2
@@ -829,7 +829,7 @@ fun UserProfileScreen(
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt },
|
||||
articleEvents.sortedWith(compareByDescending<LongTextNoteEvent> { it.publishedAt() ?: it.createdAt }.thenBy { it.id }),
|
||||
key = { "art-${it.id}" },
|
||||
) { article ->
|
||||
LongFormCard(
|
||||
@@ -871,7 +871,7 @@ fun UserProfileScreen(
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
highlightEvents.sortedByDescending { it.createdAt },
|
||||
highlightEvents.sortedWith(compareByDescending<HighlightEvent> { it.createdAt }.thenBy { it.id }),
|
||||
key = { "hl-${it.id}" },
|
||||
) { highlight ->
|
||||
PublishedHighlightCard(
|
||||
|
||||
+4
-1
@@ -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 = "",
|
||||
|
||||
+25
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Deterministic chess state reconstruction from Jester protocol events.
|
||||
@@ -59,16 +60,23 @@ object ChessStateReconstructor {
|
||||
events: JesterGameEvents,
|
||||
viewerPubkey: String,
|
||||
): ReconstructionResult {
|
||||
Log.d("chessdebug", "[Reconstructor] reconstruct() called: viewerPubkey=${viewerPubkey.take(8)}, startEvent=${events.startEvent?.id?.take(8)}, moveCount=${events.moves.size}")
|
||||
|
||||
// Step 1: Get start event (required for player info)
|
||||
val startEvent =
|
||||
events.startEvent
|
||||
?: return ReconstructionResult.Error("No start event found")
|
||||
?: run {
|
||||
Log.d("chessdebug", "[Reconstructor] ERROR: No start event found")
|
||||
return ReconstructionResult.Error("No start event found")
|
||||
}
|
||||
|
||||
val startEventId = startEvent.id
|
||||
val challengerPubkey = startEvent.pubKey
|
||||
val challengerColor = startEvent.playerColor() ?: Color.WHITE
|
||||
val challengedPubkey = startEvent.opponentPubkey()
|
||||
|
||||
Log.d("chessdebug", "[Reconstructor] startEventId=${startEventId.take(8)}, challenger=${challengerPubkey.take(8)}, challengerColor=$challengerColor, challengedPubkey=${challengedPubkey?.take(8)}")
|
||||
|
||||
// In Jester, we determine opponent from the p-tag or from move events
|
||||
// For open challenges, we need to find who made the first move
|
||||
val opponentFromMoves =
|
||||
@@ -77,6 +85,7 @@ object ChessStateReconstructor {
|
||||
?.pubKey
|
||||
|
||||
val actualOpponent = challengedPubkey ?: opponentFromMoves
|
||||
Log.d("chessdebug", "[Reconstructor] opponentFromMoves=${opponentFromMoves?.take(8)}, actualOpponent=${actualOpponent?.take(8)}")
|
||||
|
||||
// Determine players based on challenger's color choice
|
||||
val (whitePubkey, blackPubkey) =
|
||||
@@ -108,6 +117,8 @@ object ChessStateReconstructor {
|
||||
ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black
|
||||
}
|
||||
|
||||
Log.d("chessdebug", "[Reconstructor] white=${whitePubkey?.take(8)}, black=${blackPubkey?.take(8)}, viewerRole=$viewerRole, playerColor=$playerColor")
|
||||
|
||||
// Check if game is pending (no moves yet AND viewer is the challenger)
|
||||
// If viewer accepted someone else's challenge, the game is NOT pending
|
||||
val isChallenger = viewerPubkey == startEvent.pubKey
|
||||
@@ -118,6 +129,11 @@ object ChessStateReconstructor {
|
||||
val latestMove = events.latestMove()
|
||||
val history = latestMove?.history() ?: emptyList()
|
||||
|
||||
Log.d("chessdebug", "[Reconstructor] latestMove=${latestMove?.id?.take(8)}, historySize=${history.size}, isPendingChallenge=$isPendingChallenge")
|
||||
if (history.isNotEmpty()) {
|
||||
Log.d("chessdebug", "[Reconstructor] history: ${history.joinToString(" ")}")
|
||||
}
|
||||
|
||||
// Track applied moves
|
||||
val appliedMoveNumbers = mutableSetOf<Int>()
|
||||
var isDesynced = false
|
||||
@@ -131,8 +147,10 @@ object ChessStateReconstructor {
|
||||
} else {
|
||||
// Move failed - game might be desynced
|
||||
isDesynced = true
|
||||
Log.d("chessdebug", "[Reconstructor] DESYNC: move #$moveNumber '$san' failed: ${result.error}")
|
||||
// Try to recover by loading the FEN from latest move if available
|
||||
latestMove?.fen()?.let { fen ->
|
||||
Log.d("chessdebug", "[Reconstructor] Recovering with FEN: $fen")
|
||||
engine.loadFen(fen)
|
||||
}
|
||||
break
|
||||
@@ -144,6 +162,7 @@ object ChessStateReconstructor {
|
||||
val currentFen = engine.getFen()
|
||||
if (!fenPositionsMatch(currentFen, expectedFen)) {
|
||||
isDesynced = true
|
||||
Log.d("chessdebug", "[Reconstructor] FEN MISMATCH: current=$currentFen, expected=$expectedFen")
|
||||
engine.loadFen(expectedFen)
|
||||
}
|
||||
}
|
||||
@@ -154,17 +173,20 @@ object ChessStateReconstructor {
|
||||
when {
|
||||
gameResult != null -> {
|
||||
val result = parseGameResult(gameResult)
|
||||
Log.d("chessdebug", "[Reconstructor] Game finished from result tag: $gameResult -> $result")
|
||||
GameStatus.Finished(result)
|
||||
}
|
||||
|
||||
engine.isCheckmate() -> {
|
||||
val winner = engine.getSideToMove().opposite()
|
||||
Log.d("chessdebug", "[Reconstructor] Checkmate detected, winner=$winner")
|
||||
GameStatus.Finished(
|
||||
if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS,
|
||||
)
|
||||
}
|
||||
|
||||
engine.isStalemate() -> {
|
||||
Log.d("chessdebug", "[Reconstructor] Stalemate detected")
|
||||
GameStatus.Finished(GameResult.DRAW)
|
||||
}
|
||||
|
||||
@@ -197,6 +219,8 @@ object ChessStateReconstructor {
|
||||
timeControl = null, // Not supported in Jester
|
||||
)
|
||||
|
||||
Log.d("chessdebug", "[Reconstructor] Result: status=$gameStatus, appliedMoves=${appliedMoveNumbers.size}, isDesynced=$isDesynced, headEvent=${headEventId.take(8)}")
|
||||
|
||||
return ReconstructionResult.Success(state, engine)
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user