From bdf012f641b593177f8a0b98bf20b372a8996322 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 26 Nov 2024 19:07:12 -0500 Subject: [PATCH] Adds support for Interactive Stories --- .../vitorpamplona/amethyst/model/Account.kt | 164 +++++++++++++- .../amethyst/model/LocalCache.kt | 21 ++ .../service/NostrAccountDataSource.kt | 4 + .../service/NostrHashtagDataSource.kt | 80 ++++--- .../amethyst/service/NostrHomeDataSource.kt | 101 +++++---- .../NostrSearchEventOrUserDataSource.kt | 15 ++ .../service/NostrUserProfileDataSource.kt | 2 + .../ui/dal/HomeNewThreadFeedFilter.kt | 2 + .../ui/dal/UserProfileNewThreadFeedFilter.kt | 2 + .../amethyst/ui/feeds/FeedLoaded.kt | 3 + .../amethyst/ui/note/NoteCompose.kt | 13 ++ .../ui/note/types/InteractiveStory.kt | 200 ++++++++++++++++++ .../amethyst/ui/screen/FollowListState.kt | 3 + .../ui/screen/loggedIn/AccountViewModel.kt | 26 ++- .../loggedIn/threadview/ThreadFeedView.kt | 12 ++ .../vitorpamplona/quartz/events/DraftEvent.kt | 11 + .../com/vitorpamplona/quartz/events/Event.kt | 4 +- .../quartz/events/EventFactory.kt | 7 +- .../events/InteractiveStoryBaseEvent.kt | 115 ++++++++++ .../events/InteractiveStoryPrologueEvent.kt | 78 +++++++ .../InteractiveStoryReadingStateEvent.kt | 143 +++++++++++++ .../events/InteractiveStorySceneEvent.kt | 76 +++++++ .../quartz/events/LongTextNoteEvent.kt | 2 +- .../quartz/events/WikiNoteEvent.kt | 2 +- 24 files changed, 994 insertions(+), 92 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 023f44ab9..ebd30cb2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -89,6 +89,10 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent @@ -114,6 +118,7 @@ import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.StatusEvent +import com.vitorpamplona.quartz.events.StoryOption import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.TorrentCommentEvent @@ -2457,6 +2462,142 @@ class Account( } } + suspend fun createInteractiveStoryReadingState( + root: InteractiveStoryBaseEvent, + rootRelay: String?, + readingScene: InteractiveStoryBaseEvent, + readingSceneRelay: String?, + ) { + if (!isWriteable()) return + + val relayList = getPrivateOutBoxRelayList() + + InteractiveStoryReadingStateEvent.create( + root = root, + rootRelay = rootRelay, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + signer = signer, + ) { + if (relayList.isNotEmpty()) { + Client.sendPrivately(it, relayList = relayList) + } else { + Client.send(it) + } + LocalCache.justConsume(it, null) + } + } + + suspend fun updateInteractiveStoryReadingState( + readingState: InteractiveStoryReadingStateEvent, + readingScene: InteractiveStoryBaseEvent, + readingSceneRelay: String?, + ) { + if (!isWriteable()) return + + val relayList = getPrivateOutBoxRelayList() + + InteractiveStoryReadingStateEvent.update( + base = readingState, + currentScene = readingScene, + currentSceneRelay = readingSceneRelay, + signer = signer, + ) { + if (relayList.isNotEmpty()) { + Client.sendPrivately(it, relayList = relayList) + } else { + Client.send(it) + } + LocalCache.justConsume(it, null) + } + } + + suspend fun sendInteractiveStoryPrologue( + baseId: String, + title: String, + content: String, + options: List, + summary: String? = null, + image: String? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + nip94attachments: List? = null, + draftTag: String? = null, + relayList: List, + ) { + if (!isWriteable()) return + + InteractiveStoryPrologueEvent.create( + baseId = baseId, + title = title, + content = content, + options = options, + summary = summary, + image = image, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + nip94attachments = nip94attachments, + signer = signer, + isDraft = draftTag != null, + ) { + if (draftTag != null) { + if (content.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + } + } + } + + suspend fun sendInteractiveStoryScene( + baseId: String, + title: String, + content: String, + options: List, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + nip94attachments: List? = null, + draftTag: String? = null, + relayList: List, + ) { + if (!isWriteable()) return + + InteractiveStorySceneEvent.create( + baseId = baseId, + title = title, + content = content, + options = options, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + nip94attachments = nip94attachments, + signer = signer, + isDraft = draftTag != null, + ) { + if (draftTag != null) { + if (content.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + } + } + } + suspend fun sendPost( message: String, replyTo: List?, @@ -2823,18 +2964,19 @@ class Account( } } - fun sendDraftEvent(draftEvent: DraftEvent) { - val relayList = - normalizedPrivateOutBoxRelaySet.value.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - true, - true, - emptySet(), - ) - } + fun getPrivateOutBoxRelayList(): List = + normalizedPrivateOutBoxRelaySet.value.map { + RelaySetupInfoToConnect( + it, + shouldUseTorForClean(it), + true, + true, + emptySet(), + ) + } + fun sendDraftEvent(draftEvent: DraftEvent) { + val relayList = getPrivateOutBoxRelayList() if (relayList.isNotEmpty()) { Client.sendPrivately(draftEvent, relayList) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b58f87478..dc6f74c66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -86,6 +86,9 @@ import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LnZapEvent @@ -449,6 +452,21 @@ object LocalCache { relay: Relay?, ) = consumeRegularEvent(event, relay) + fun consume( + event: InteractiveStoryPrologueEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + + fun consume( + event: InteractiveStorySceneEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + + fun consume( + event: InteractiveStoryReadingStateEvent, + relay: Relay?, + ) = consumeBaseReplaceable(event, relay) + fun consumeRegularEvent( event: Event, relay: Relay?, @@ -2393,6 +2411,9 @@ object LocalCache { is GitPatchEvent -> consume(event, relay) is GitRepositoryEvent -> consume(event, relay) is HighlightEvent -> consume(event, relay) + is InteractiveStoryPrologueEvent -> consume(event, relay) + is InteractiveStorySceneEvent -> consume(event, relay) + is InteractiveStoryReadingStateEvent -> consume(event, relay) is LiveActivitiesEvent -> consume(event, relay) is LiveActivitiesChatMessageEvent -> consume(event, relay) is LnZapEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 60d7a528e..465caa9bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -56,6 +56,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -245,6 +247,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), limit = 400, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 86284c410..b53b6389d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -38,46 +39,59 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { private var hashtagToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = hashtagToWatch ?: return null + fun createLoadHashtagFilter(): List { + val hashToLoad = hashtagToWatch ?: return emptyList() - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "t" to - listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize(), - ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 200, - ), + val hashtagsToFollow = + listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize(), + ) + + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = mapOf("t" to hashtagsToFollow), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ), + limit = 200, + ), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = mapOf("t" to hashtagsToFollow), + kinds = + listOf( + InteractiveStorySceneEvent.KIND, + ), + limit = 200, + ), + ), ) } val loadHashtagChannel = requestNewChannel() override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null } } fun loadHashtag(tag: String?) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index b1a5e5dfb..f37fe1abd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -86,38 +87,57 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { job2?.cancel() } - fun createFollowAccountsFilter(): TypedFilter { + fun createFollowAccountsFilter(): List { val follows = account.liveHomeListAuthorsPerRelay.value - return TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = follows, - limit = 400, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), + return listOf( + TypedFilter( + types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + kinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + ), + authors = follows, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultHomeFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + kinds = + listOf( + InteractiveStoryPrologueEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + WikiNoteEvent.KIND, + ), + authors = follows, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultHomeFollowList.value) + ?.relayList, + ), + ), ) } @@ -165,7 +185,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, + InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, WikiNoteEvent.KIND, ), @@ -203,8 +223,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, + InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, CommentEvent.KIND, ), @@ -240,12 +259,10 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, WikiNoteEvent.KIND, CommunityPostApprovalEvent.KIND, CommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ), tags = mapOf( @@ -273,12 +290,14 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { override fun updateChannelFilters() { followAccountChannel.typedFilters = - listOfNotNull( - createFollowAccountsFilter(), - createFollowMetadataAndReleaseFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), + ( + createFollowAccountsFilter() + + listOfNotNull( + createFollowMetadataAndReleaseFilter(), + createFollowCommunitiesFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), + ) ).ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index fab8c6bc8..1d74f5fa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -184,6 +186,19 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe limit = 100, ), ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index daa439767..274cebffb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -101,6 +102,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { listOf( TorrentEvent.KIND, TorrentCommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ), authors = listOf(it.pubkeyHex), limit = 20, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 3262e8e8c..b6bcc7d9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -99,6 +100,7 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 5bc32e006..0f8b99305 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -73,6 +74,7 @@ class UserProfileNewThreadFeedFilter( it.event is WikiNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || + it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || it.event is AudioHeaderEvent || it.event is TorrentEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 5c0a52a53..44fc509eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.navigation.INav @@ -48,6 +49,8 @@ fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + LazyColumn( contentPadding = FeedPadding, state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8ec43c9c0..b3ee8c494 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent @@ -171,6 +172,7 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -798,6 +800,17 @@ private fun RenderNoteRow( nav, ) + is InteractiveStoryBaseEvent -> + RenderInteractiveStory( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + else -> { RenderTextEvent( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt new file mode 100644 index 000000000..6d857a0af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2024 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.ui.note.types + +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.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent + +@Composable +fun RenderInteractiveStory( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val baseRootEvent = baseNote.event as? InteractiveStoryBaseEvent ?: return + val address = baseNote.address() ?: return + + // keep updating the root event with new versions + val note = baseNote.live().metadata.observeAsState() + val rootEvent = note.value?.note?.event as? InteractiveStoryBaseEvent ?: return + + // keep updating the reading state event with new versions + val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toTag()) + val latestReadingNoteState = readingStateNote.live().metadata.observeAsState() + val readingState = latestReadingNoteState.value?.note?.event as? InteractiveStoryReadingStateEvent + + val currentScene = readingState?.currentScene() + + if (currentScene != null && currentScene != rootEvent.address()) { + LoadAddressableNote(currentScene, accountViewModel) { currentSceneBaseNote -> + val currentScene = currentSceneBaseNote?.live()?.metadata?.observeAsState() + val currentSceneEvent = currentScene?.value?.note?.event as? InteractiveStoryBaseEvent + + if (currentSceneEvent != null) { + RenderInteractiveStory( + section = currentSceneEvent, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else { + RenderInteractiveStory( + section = rootEvent, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun RenderInteractiveStory( + section: InteractiveStoryBaseEvent, + onSelect: (AddressableNote) -> Unit, + onRestart: () -> Unit, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + section.title()?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 5.dp, bottom = 10.dp), + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + TranslatableRichTextViewer( + content = section.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = section.id, + callbackUri = null, + accountViewModel = accountViewModel, + nav = nav, + ) + + val options = section.options() + + if (options.isNotEmpty()) { + Column(Modifier.padding(top = 10.dp)) { + options.forEach { opt -> + LoadAddressableNote(opt.address, accountViewModel) { note -> + if (note != null) { + val optionState = note.live().metadata.observeAsState() + + OutlinedButton( + onClick = { onSelect(note) }, + ) { + Text(opt.option) + } + } + } + } + } + } else { + Column(Modifier.padding(top = 10.dp)) { + OutlinedButton( + onClick = onRestart, + ) { + Text("Restart") + } + } + } +} + +@Stable +class StoryReadingState { + private var sectionList = mutableMapOf() + private var sectionToShowId: HexKey? = null + + val sectionToShow: MutableState = mutableStateOf(null) + + fun readSection(note: Note) { + sectionList[note.idHex] = note + sectionToShowId = note.idHex + sectionToShow.value = note + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index 023386c92..ee5812705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -354,6 +355,7 @@ val DEFAULT_FEED_KINDS = LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, WikiNoteEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) val DEFAULT_COMMUNITY_FEEDS = @@ -367,4 +369,5 @@ val DEFAULT_COMMUNITY_FEEDS = PinListEvent.KIND, WikiNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9d54c89c2..6b0129f2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -89,6 +89,8 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent @@ -1102,7 +1104,7 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } } - suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? = LocalCache.getOrCreateAddressableNote(key) + suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote = LocalCache.getOrCreateAddressableNote(key) fun getOrCreateAddressableNote( key: ATag, @@ -1558,6 +1560,28 @@ class AccountViewModel( AdvertisedRelayListEvent.createAddressTag(user.pubkeyHex), ) + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddressATag(account.signer.pubKey, dATag)) + + fun updateInteractiveStoryReadingState( + root: InteractiveStoryBaseEvent, + readingScene: InteractiveStoryBaseEvent, + ) { + viewModelScope.launch(Dispatchers.IO) { + val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl() + + val readingState = getInteractiveStoryReadingState(root.addressTag()) + val readingStateEvent = readingState.event as? InteractiveStoryReadingStateEvent + + if (readingStateEvent != null) { + account.updateInteractiveStoryReadingState(readingStateEvent, readingScene, sceneNoteRelayHint) + } else { + val rootNoteRelayHint = LocalCache.getOrCreateAddressableNote(root.address()).relayHintUrl() + + account.createInteractiveStoryReadingState(root, rootNoteRelayHint, readingScene, sceneNoteRelayHint) + } + } + } + fun sendSats( lnaddress: String, milliSats: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 70e4a57f6..8fe28c409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll @@ -184,6 +185,7 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -562,6 +564,16 @@ private fun FullBleedNoteCompose( RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is InteractiveStoryBaseEvent) { + RenderInteractiveStory( + baseNote, + false, + true, + 3, + backgroundColor, + accountViewModel, + nav, + ) } else if (noteEvent is GitPatchEvent) { RenderGitPatchEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is GitIssueEvent) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt index e17c91766..3285ae1a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -132,6 +132,17 @@ class DraftEvent( create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) } + fun create( + dTag: String, + originalNote: InteractiveStoryBaseEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + create(dTag, originalNote, tags, signer, createdAt, onReady) + } + fun create( dTag: String, originalNote: LiveActivitiesChatMessageEvent, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index bb2ee18d2..0ea039b70 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -556,7 +556,7 @@ class HostStub( interface AddressableEvent { fun dTag(): String - fun address(): ATag + fun address(relayHint: String? = null): ATag fun addressTag(): String } @@ -574,7 +574,7 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) /** * Creates the tag in a memory effecient way (without creating the ATag class diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 4f3411ea8..065b3b25e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.events.nip46.NostrConnectEvent class EventFactory { companion object { - val additionalFactories: MutableMap>, String, HexKey) -> Event> = mutableMapOf() + val factories: MutableMap>, String, HexKey) -> Event> = mutableMapOf() fun create( id: String, @@ -99,6 +99,9 @@ class EventFactory { GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) + InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) @@ -141,7 +144,7 @@ class EventFactory { VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> { - additionalFactories[kind]?.let { + factories[kind]?.let { return it(id, pubKey, createdAt, tags, content, sig) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt new file mode 100644 index 000000000..0388713ee --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments + +open class InteractiveStoryBaseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + fun title() = firstTag("title") + + fun summary() = firstTag("summary") + + fun image() = firstTag("image") + + fun options() = + tags + .filter { it.size > 2 && it[0] == "option" } + .mapNotNull { ATag.parse(it[2], it.getOrNull(3))?.let { aTag -> StoryOption(it[1], aTag) } } + + companion object { + fun generalTags( + content: String, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + ): Array> { + val tags = mutableListOf>() + findHashtags(content).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + findURLs(content).forEach { tags.add(arrayOf("r", it)) } + + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } + return tags.toTypedArray() + } + + fun makeTags( + baseId: String, + alt: String, + title: String, + summary: String? = null, + image: String? = null, + options: List = emptyList(), + ): Array> = + ( + listOfNotNull( + arrayOf("d", baseId), + arrayOf("title", title), + summary?.let { arrayOf("summary", it) }, + image?.let { arrayOf("image", it) }, + arrayOf("alt", alt), + ) + + options.map { + val relayUrl = it.address.relay + if (relayUrl != null) { + arrayOf("option", it.option, it.address.toTag(), relayUrl) + } else { + arrayOf("option", it.option, it.address.toTag()) + } + } + ).toTypedArray() + } +} + +class StoryOption( + val option: String, + val address: ATag, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt new file mode 100644 index 000000000..929414009 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class InteractiveStoryPrologueEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30296 + const val ALT = "The prologue of an interative story called " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun create( + baseId: String, + title: String, + content: String, + options: List, + summary: String? = null, + image: String? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + isDraft: Boolean, + onReady: (InteractiveStoryPrologueEvent) -> Unit, + ) { + val tags = + makeTags(baseId, ALT + title, title, summary, image, options) + + generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments) + + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags, content, onReady) + } else { + signer.sign(createdAt, KIND, tags, content, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt new file mode 100644 index 000000000..0c0228554 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryReadingStateEvent.kt @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +class InteractiveStoryReadingStateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = firstTag("title") + + fun summary() = firstTag("summary") + + fun image() = firstTag("image") + + fun status() = firstTag("status") + + fun root() = + tags.firstOrNull { it.size > 1 && it[0] == "A" }?.let { + ATag.parse(it[1], it.getOrNull(2)) + } + + fun currentScene() = + tags.firstOrNull { it.size > 1 && it[0] == "a" }?.let { + ATag.parse(it[1], it.getOrNull(2)) + } + + companion object { + const val KIND = 30298 + const val ALT1 = "Interactive Story Reading state" + const val ALT2 = "The reading state of " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun update( + base: InteractiveStoryReadingStateEvent, + currentScene: InteractiveStoryBaseEvent, + currentSceneRelay: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (InteractiveStoryReadingStateEvent) -> Unit, + ) { + val rootTag = base.dTag() + val sceneTag = currentScene.addressTag() + + val status = + if (rootTag == sceneTag) { + "new" + } else if (currentScene.options().isEmpty()) { + "done" + } else { + "reading" + } + + val tags = + base.tags.filter { it[0] != "a" && it[0] != "status" } + + listOf( + removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay), + arrayOf("status", status), + ) + + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } + + fun create( + root: InteractiveStoryBaseEvent, + rootRelay: String?, + currentScene: InteractiveStoryBaseEvent, + currentSceneRelay: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (InteractiveStoryReadingStateEvent) -> Unit, + ) { + val rootTag = root.addressTag() + val sceneTag = currentScene.addressTag() + val status = + if (rootTag == sceneTag) { + "new" + } else if (currentScene.options().isEmpty()) { + "done" + } else { + "reading" + } + + val tags = + listOfNotNull( + arrayOf("d", rootTag), + arrayOf("alt", root.title()?.let { ALT2 + it } ?: ALT1), + root.title()?.let { arrayOf("title", it) }, + root.summary()?.let { arrayOf("summary", it) }, + root.image()?.let { arrayOf("image", it) }, + removeTrailingNullsAndEmptyOthers("A", rootTag, rootRelay), + removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay), + arrayOf("status", status), + ).toTypedArray() + + signer.sign(createdAt, KIND, tags, "", onReady) + } + } + + enum class ReadingStatus { + NEW, + READING, + DONE, + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt new file mode 100644 index 000000000..cb5f2cf37 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class InteractiveStorySceneEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30297 + const val ALT = "A scene of an interative story called " + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = ATag.assembleATag(KIND, pubKey, dtag) + + fun create( + baseId: String, + title: String, + content: String, + options: List, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + isDraft: Boolean, + onReady: (InteractiveStorySceneEvent) -> Unit, + ) { + val tags = + makeTags(baseId, ALT + title, title, options = options) + + generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments) + + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags, content, onReady) + } else { + signer.sign(createdAt, KIND, tags, content, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt index 7fbe65d57..b06909e27 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt @@ -38,7 +38,7 @@ class LongTextNoteEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt index f92d8730a..b6cc81eea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt @@ -38,7 +38,7 @@ class WikiNoteEvent( AddressableEvent { override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag())