From 4db6f5531c0b679eae73b5c7aee5de2ef091d397 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 19 Nov 2024 15:36:30 -0500 Subject: [PATCH] Adds support for Instagram feeds. https://github.com/nostr-protocol/nips/pull/1551 --- .../vitorpamplona/amethyst/model/Account.kt | 85 +++- .../amethyst/model/LocalCache.kt | 477 ++---------------- .../amethyst/service/NostrVideoDataSource.kt | 179 ++++--- .../amethyst/ui/actions/NewPostViewModel.kt | 2 +- .../amethyst/ui/components/VideoView.kt | 9 +- .../ui/components/ZoomableContentView.kt | 26 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 11 +- .../amethyst/ui/note/NoteCompose.kt | 4 + .../amethyst/ui/note/types/PictureDisplay.kt | 135 +++++ .../ui/screen/loggedIn/AccountViewModel.kt | 3 +- .../screen/loggedIn/profile/ProfileGallery.kt | 10 +- .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../ui/screen/loggedIn/video/VideoScreen.kt | 10 +- .../commons/richtext/MediaContentModels.kt | 15 +- .../commons/richtext/RichTextParser.kt | 5 +- .../quartz/encoders/Nip92MediaAttachments.kt | 27 +- .../quartz/events/CommentEvent.kt | 27 + .../quartz/events/EventFactory.kt | 1 + .../quartz/events/FileHeaderEvent.kt | 2 +- .../quartz/events/FileStorageHeaderEvent.kt | 2 +- .../quartz/events/PictureEvent.kt | 322 ++++++++++++ .../quartz/events/ProfileGalleryEntryEvent.kt | 6 +- .../vitorpamplona/quartz/events/VideoEvent.kt | 2 +- 23 files changed, 791 insertions(+), 573 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.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 57e890f41..e05862066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.Contact import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent +import com.vitorpamplona.quartz.events.Dimension import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent @@ -2190,34 +2191,68 @@ class Account( } } - CommentEvent.replyComment( - msg = message, - replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), - usersMentioned = usersMentioned, - addressesMentioned = addressesMentioned, - eventsMentioned = eventsMentioned, - nip94attachments = nip94attachments, - geohash = geohash, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - isDraft = draftTag != null, - signer = signer, - ) { - if (draftTag != null) { - if (message.isBlank()) { - deleteDraft(draftTag) + if (replyingTo.event is CommentEvent) { + CommentEvent.replyComment( + msg = message, + replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + nip94attachments = nip94attachments, + geohash = geohash, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + isDraft = draftTag != null, + signer = signer, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } } else { - DraftEvent.create(draftTag, it, signer) { draftEvent -> - sendDraftEvent(draftEvent) + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyingTo.event?.let { + Client.send(it, relayList = relayList) } } - } else { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyingTo.event?.let { + } + } else { + CommentEvent.firstReplyToEvent( + msg = message, + replyingTo = EventHint(replyingTo.event as Event, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + nip94attachments = nip94attachments, + geohash = geohash, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + isDraft = draftTag != null, + signer = signer, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyingTo.event?.let { + Client.send(it, relayList = relayList) + } } } } @@ -2882,7 +2917,7 @@ class Account( url: String, relay: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, ) { 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 48fbf53ce..09e236537 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeDefinitionEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BaseAddressableEvent +import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.CalendarDateSlotEvent import com.vitorpamplona.quartz.events.CalendarEvent @@ -101,6 +102,7 @@ import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NNSEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -439,61 +441,15 @@ object LocalCache { fun consume( event: TextNoteEvent, relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: TorrentEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + ) = consumeRegularEvent(event, relay) - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - fun consume( - event: TorrentCommentEvent, + fun consumeRegularEvent( + event: Event, relay: Relay?, ) { val note = getOrCreateNote(event.id) @@ -507,242 +463,69 @@ object LocalCache { // Already processed this event. if (note.event != null) return - if (antiSpam.isSpam(event, relay)) { + val replyTo = computeReplyTo(event) + + if (event is BaseTextNoteEvent && antiSpam.isSpam(event, relay)) { return } - val replyTo = computeReplyTo(event) - note.loadEvent(event, author, replyTo) // Counts the replies - replyTo.forEach { - it.addReply(note) - } + replyTo.forEach { it.addReply(note) } refreshObservers(note) } + fun consume( + event: PictureEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) + + fun consume( + event: TorrentCommentEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) + fun consume( event: NIP90ContentDiscoveryResponseEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}") - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90ContentDiscoveryRequestEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90StatusEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90UserDiscoveryResponseEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: NIP90UserDiscoveryRequestEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitPatchEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitIssueEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: GitReplyEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}") - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) fun consume( event: LongTextNoteEvent, @@ -869,35 +652,8 @@ object LocalCache { fun consume( event: PollNoteEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + relay: Relay?, + ) = consumeRegularEvent(event, relay) private fun consume( event: LiveActivitiesEvent, @@ -1137,25 +893,10 @@ object LocalCache { } } - fun consume(event: BadgeAwardEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val awardDefinition = computeReplyTo(event) - - note.loadEvent(event, author, awardDefinition) - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { it.addReply(note) } - - refreshObservers(note) - } + fun consume( + event: BadgeAwardEvent, + relay: Relay?, + ) = consumeRegularEvent(event, relay) private fun comsume( event: NNSEvent, @@ -1638,31 +1379,7 @@ object LocalCache { fun consume( event: CommentEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: LiveActivitiesChatMessageEvent, @@ -1759,102 +1476,27 @@ object LocalCache { fun consume( event: AudioHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: ProfileGalleryEntryEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileStorageHeaderEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FhirResourceEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: TextNoteModificationEvent, @@ -1887,22 +1529,7 @@ object LocalCache { fun consume( event: HighlightEvent, relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + ) = consumeRegularEvent(event, relay) fun consume( event: FileStorageEvent, @@ -2127,8 +1754,7 @@ object LocalCache { } if (note.event?.matchTag1With(text) == true || - note.idHex.startsWith(text, true) || - note.idNote().startsWith(text, true) + note.idHex.startsWith(text, true) ) { if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { return@filter true @@ -2713,7 +2339,7 @@ object LocalCache { is AppSpecificDataEvent -> consume(event, relay) is AudioHeaderEvent -> consume(event, relay) is AudioTrackEvent -> consume(event, relay) - is BadgeAwardEvent -> consume(event) + is BadgeAwardEvent -> consume(event, relay) is BadgeDefinitionEvent -> consume(event, relay) is BadgeProfilesEvent -> consume(event) is BookmarkListEvent -> consume(event) @@ -2780,6 +2406,7 @@ object LocalCache { is MuteListEvent -> consume(event, relay) is NNSEvent -> comsume(event, relay) is OtsEvent -> consume(event, relay) + is PictureEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay) is PrivateOutboxRelayListEvent -> consume(event, relay) is PinListEvent -> consume(event, relay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 2942ec92e..dee073870 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import kotlinx.coroutines.Dispatchers @@ -64,85 +65,139 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { job?.cancel() } - fun createContextualFilter(): TypedFilter { + fun createContextualFilter(): List { val follows = account.liveStoriesListAuthorsPerRelay.value - return TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS) + + return listOf( + TypedFilter( + types = types, + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + limit = 200, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = types, + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + limit = 200, + tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } - fun createFollowTagsFilter(): TypedFilter? { + fun createFollowTagsFilter(): List { val hashToLoad = account.liveStoriesFollowLists.value ?.hashtags - ?.toList() ?: return null + ?.toList() ?: return emptyList() - if (hashToLoad.isEmpty()) return null + if (hashToLoad.isEmpty()) return emptyList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val hashtags = + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten() + + return listOf( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "t" to hashtags, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } - fun createFollowGeohashesFilter(): TypedFilter? { + fun createFollowGeohashesFilter(): List { val hashToLoad = account.liveStoriesFollowLists.value ?.geotags - ?.toList() ?: return null + ?.toList() ?: return emptyList() - if (hashToLoad.isEmpty()) return null + if (hashToLoad.isEmpty()) return emptyList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), + val geoHashes = + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten() + + return listOf( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "g" to geoHashes, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.settings.defaultStoriesFollowList.value) + ?.relayList, + ), + ), ) } @@ -162,6 +217,6 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { createContextualFilter(), createFollowTagsFilter(), createFollowGeohashesFilter(), - ).ifEmpty { null } + ).flatten().ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index b4c118a5c..12a3e1359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -554,7 +554,7 @@ open class NewPostViewModel : ViewModel() { val replyingTo = originalNote - if (replyingTo?.event is CommentEvent) { + if (replyingTo?.event is CommentEvent || replyingTo?.event is Event) { account?.sendReplyComment( message = tagger.message, replyingTo = replyingTo, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 3acea4ae1..cdef81fb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.events.Dimension import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -211,7 +212,7 @@ fun VideoView( waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -242,7 +243,7 @@ fun VideoView( waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -260,7 +261,7 @@ fun VideoView( } if (blurhash == null) { - val ratio = aspectRatio(dimensions) + val ratio = dimensions?.aspectRatio() val modifier = if (ratio != null && automaticallyStartPlayback.value) { Modifier.aspectRatio(ratio) @@ -294,7 +295,7 @@ fun VideoView( } } } else { - val ratio = aspectRatio(dimensions) + val ratio = dimensions?.aspectRatio() val modifier = if (ratio != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e6abe6698..c55bf4396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -98,9 +98,9 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.Dimension import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -389,7 +389,7 @@ fun UrlImageView( accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { - val ratio = remember(content) { aspectRatio(content.dim) } + val ratio = content.dim?.aspectRatio() val showImage = remember { @@ -552,26 +552,10 @@ fun ShowHash(content: MediaUrlContent) { verifiedHash?.let { HashVerificationSymbol(it) } } -fun aspectRatio(dim: String?): Float? { +fun aspectRatio(dim: Dimension?): Float? { if (dim == null) return null - if (dim == "0x0") return null - val parts = dim.split("x") - if (parts.size != 2) return null - - return try { - val width = parts[0].toFloat() - val height = parts[1].toFloat() - - if (width < 0.1 || height < 0.1) { - null - } else { - width / height - } - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } + return dim.width.toFloat() / dim.height.toFloat() } @Composable @@ -719,7 +703,7 @@ fun ShareImageAction( videoUri: String?, postNostrUri: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, onDismiss: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 6cdacc81c..3904081ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.dal +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent @@ -66,10 +68,11 @@ class VideoFeedFilter( val noteEvent = it.event return ( - (noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) + (noteEvent is FileHeaderEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) || + (noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) || + (noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) || + (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + noteEvent is PictureEvent ) && params.match(noteEvent) && (params.isHiddenList || account.isAcceptable(it)) 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 a4defbdfa..8ec43c9c0 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 @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EmptyState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack @@ -176,6 +177,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -730,6 +732,8 @@ private fun RenderNoteRow( is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel) is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav) is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav) + is PictureEvent -> PictureDisplay(baseNote, true, false, backgroundColor, accountViewModel, nav) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel) is CommunityPostApprovalEvent -> { RenderPostApproval( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt new file mode 100644 index 000000000..353d3b505 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -0,0 +1,135 @@ +/** + * 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.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PictureEvent +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun PictureDisplay( + note: Note, + roundedCorner: Boolean, + isFiniteHeight: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? PictureEvent) ?: return + val uri = note.toNostrUri() + + val images by + remember(note) { + mutableStateOf( + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + ) + }.toImmutableList(), + ) + } + + val first = images.firstOrNull() + + if (first != null) { + val title = event.title() + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column { + if (title != null) { + Text( + modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding, + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(StdVertSpacer) + } + + ZoomableContentView( + content = first, + images = images, + roundedCorner = roundedCorner, + isFiniteHeight = isFiniteHeight, + accountViewModel = accountViewModel, + ) + + TranslatableRichTextViewer( + content = event.content, + canPreview = false, + quotesLeft = 0, + modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding, + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = uri, + accountViewModel = accountViewModel, + nav = nav, + ) + + /* + TranslatableRichTextViewer( + content = , + modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding, + + + + text = , + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + )*/ + } + } + } +} 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 f7abdb54e..9d54c89c2 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 @@ -83,6 +83,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable +import com.vitorpamplona.quartz.events.Dimension import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface @@ -754,7 +755,7 @@ class AccountViewModel( url: String, relay: String?, blurhash: String?, - dim: String?, + dim: Dimension?, hash: String?, mimeType: String?, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt index 1d87e67f0..887a8d186 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileGallery.kt @@ -338,6 +338,8 @@ fun InnerRenderGalleryThumb( note: Note, accountViewModel: AccountViewModel, ) { + val noteEvent = note.event as? ProfileGalleryEntryEvent ?: return + Box( Modifier .fillMaxWidth() @@ -345,11 +347,11 @@ fun InnerRenderGalleryThumb( contentAlignment = BottomStart, ) { card.image?.let { - var blurHash = (note.event as ProfileGalleryEntryEvent).blurhash() - var description = (note.event as ProfileGalleryEntryEvent).content + val blurHash = noteEvent.blurhash() + val description = noteEvent.content // var hash = (note.event as ProfileGalleryEntryEvent).hash() - var dimensions = (note.event as ProfileGalleryEntryEvent).dimensions() - var mimeType = (note.event as ProfileGalleryEntryEvent).mimeType() + val dimensions = noteEvent.dimensions() + val mimeType = noteEvent.mimeType() var content: BaseMediaContent? = null if (isVideoUrl(it)) { 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 ebfef9a94..70e4a57f6 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 @@ -123,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -186,6 +187,7 @@ import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -509,6 +511,8 @@ private fun FullBleedNoteCompose( ) } else if (noteEvent is VideoEvent) { VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, isFiniteHeight = false, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is PictureEvent) { + PictureDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, backgroundColor, accountViewModel = accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index c8320a038..6e6b48726 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -43,9 +43,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.stringRes @@ -101,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.VideoReactionColumnPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.PictureEvent import com.vitorpamplona.quartz.events.VideoEvent @Composable @@ -290,7 +292,11 @@ private fun RenderVideoOrPictureNote( Column(Modifier.fillMaxSize(1f), verticalArrangement = Arrangement.Center) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { val noteEvent = remember { note.event } - if (noteEvent is FileHeaderEvent) { + if (noteEvent is PictureEvent) { + val backgroundColor = remember { mutableStateOf(Color.Transparent) } + + PictureDisplay(note, false, true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note, false, true, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(note, false, true, accountViewModel) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index acee3f8ee..16d564099 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -21,12 +21,13 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.events.Dimension import java.io.File @Immutable abstract class BaseMediaContent( val description: String? = null, - val dim: String? = null, + val dim: Dimension? = null, val blurhash: String? = null, ) @@ -35,7 +36,7 @@ abstract class MediaUrlContent( val url: String, description: String? = null, val hash: String? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, val uri: String? = null, val mimeType: String? = null, @@ -47,7 +48,7 @@ class MediaUrlImage( description: String? = null, hash: String? = null, blurhash: String? = null, - dim: String? = null, + dim: Dimension? = null, uri: String? = null, val contentWarning: String? = null, mimeType: String? = null, @@ -58,7 +59,7 @@ class MediaUrlVideo( url: String, description: String? = null, hash: String? = null, - dim: String? = null, + dim: Dimension? = null, uri: String? = null, val artworkUri: String? = null, val authorName: String? = null, @@ -73,7 +74,7 @@ abstract class MediaPreloadedContent( description: String? = null, val mimeType: String? = null, val isVerified: Boolean? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, val uri: String, val id: String? = null, @@ -86,7 +87,7 @@ class MediaLocalImage( localFile: File?, mimeType: String? = null, description: String? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, isVerified: Boolean? = null, uri: String, @@ -97,7 +98,7 @@ class MediaLocalVideo( localFile: File?, mimeType: String? = null, description: String? = null, - dim: String? = null, + dim: Dimension? = null, blurhash: String? = null, isVerified: Boolean? = null, uri: String, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 8581d9eb0..f5b5030a3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -27,6 +27,7 @@ import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.events.Dimension import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList @@ -56,7 +57,7 @@ class RichTextParser { description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) }, contentWarning = frags["content-warning"] ?: tags["content-warning"], uri = callbackUri, mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], @@ -76,7 +77,7 @@ class RichTextParser { description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) }, contentWarning = frags["content-warning"] ?: tags["content-warning"], uri = callbackUri, mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt index 6bfc6ece1..f98f1e9c4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent class Nip92MediaAttachments { companion object { - private const val IMETA = "imeta" + const val IMETA = "imeta" } fun convertFromFileHeader(header: FileHeaderEvent): Array? { @@ -63,13 +63,22 @@ class Nip92MediaAttachments { .firstOrNull { it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl" }?.let { tagList -> - tagList.associate { tag -> - val parts = tag.split(" ", limit = 2) - when (parts.size) { - 2 -> parts[0] to parts[1] - 1 -> parts[0] to "" - else -> "" to "" - } - } + parseIMeta(tagList) } ?: emptyMap() + + fun parse(tags: Array>): Map> = + tags.filter { it.size > 1 && it[0] == IMETA }.associate { + val allTags = parseIMeta(it) + (allTags.get("url") ?: "") to allTags + } + + fun parseIMeta(tags: Array): Map = + tags.associate { tag -> + val parts = tag.split(" ", limit = 2) + when (parts.size) { + 2 -> parts[0] to parts[1] + 1 -> parts[0] to "" + else -> "" to "" + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt index 938ec4a2d..90d2ef0d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt @@ -87,6 +87,33 @@ class CommentEvent( .reversed() .toTypedArray() + fun firstReplyToEvent( + msg: String, + replyingTo: EventHint, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + nip94attachments: List? = null, + geohash: String? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + isDraft: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommentEvent) -> Unit, + ) { + val tags = mutableListOf>() + + tags.add(removeTrailingNullsAndEmptyOthers("E", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) + tags.add(arrayOf("K", "${replyingTo.event.kind}")) + + tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) + tags.add(arrayOf("k", "${replyingTo.event.kind}")) + + create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) + } + fun replyComment( msg: String, replyingTo: EventHint, 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 12e980a6e..44d65a222 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -118,6 +118,7 @@ class EventFactory { NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 02052e8e0..47dbe6275 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -46,7 +46,7 @@ class FileHeaderEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index e77249778..f2659a9a6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -44,7 +44,7 @@ class FileStorageHeaderEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt new file mode 100644 index 000000000..6a02ae018 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt @@ -0,0 +1,322 @@ +/** + * 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.ETag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA +import com.vitorpamplona.quartz.encoders.PTag +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ALT +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.BLUR_HASH +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.DIMENSION +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.FILE_SIZE +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MAGNET_URI +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH +import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.URL +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.coroutines.cancellation.CancellationException + +@Immutable +class PictureEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun mimeTypes() = tags.filter { it.size > 1 && it[0] == MIME_TYPE } + + fun hashes() = tags.filter { it.size > 1 && it[0] == HASH } + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE }?.get(1) + + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == FileHeaderEvent.HASH }?.get(1) + + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + + // hack to fix pablo's bug + fun rootImage() = + url()?.let { + PictureMeta( + url = it, + mimeType = mimeType(), + blurhash = blurhash(), + alt = alt(), + hash = hash(), + dimension = dimensions(), + size = size()?.toLongOrNull(), + fallback = emptyList(), + annotations = emptyList(), + ) + } + + fun imetaTags() = + tags + .map { tagArray -> + if (tagArray.size > 1 && tagArray[0] == IMETA) { + PictureMeta.parse(tagArray) + } else { + null + } + }.plus(rootImage()) + .filterNotNull() + + companion object { + const val KIND = 20 + const val ALT_DESCRIPTION = "Picture" + + private const val MIME_TYPE = "m" + private const val HASH = "x" + private const val TITLE = "title" + + private fun create( + msg: String, + tags: MutableList>, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + nip94attachments: List? = null, + geohash: String? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + isDraft: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PictureEvent) -> Unit, + ) { + usersMentioned.forEach { tags.add(it.toPTagArray()) } + addressesMentioned.forEach { tags.add(it.toQTagArray()) } + eventsMentioned.forEach { tags.add(it.toQTagArray()) } + + findHashtags(msg).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + + findURLs(msg).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) + } + } + } + + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } else { + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + } + } +} + +class PictureMeta( + val url: String, + val mimeType: String?, + val blurhash: String?, + val dimension: Dimension?, + val alt: String?, + val hash: String?, + val size: Long?, + val fallback: List, + val annotations: List, +) { + companion object { + fun parse(tagArray: Array): PictureMeta? { + var url: String? = null + var mimeType: String? = null + var blurhash: String? = null + var dim: Dimension? = null + var alt: String? = null + var hash: String? = null + var size: Long? = null + val fallback = mutableListOf() + val annotations = mutableListOf() + + if (tagArray.size == 2 && tagArray[1].contains("url") && (tagArray[1].contains("blurhash") || tagArray[1].contains("size"))) { + // hack to fix pablo's bug + val keys = setOf("url", "m", "blurhash", "dim", "alt", "x", "size", "fallback", "annotate-user") + var keyNextValue: String? = null + val values = mutableListOf() + + tagArray[1].split(" ").forEach { + if (it in keys) { + if (keyNextValue != null && values.isNotEmpty()) { + when (keyNextValue) { + "url" -> url = values.joinToString(" ") + "m" -> mimeType = values.joinToString(" ") + "blurhash" -> blurhash = values.joinToString(" ") + "dim" -> dim = Dimension.parse(values.joinToString(" ")) + "alt" -> alt = values.joinToString(" ") + "x" -> hash = values.joinToString(" ") + "size" -> size = values.joinToString(" ").toLongOrNull() + "fallback" -> fallback.add(values.joinToString(" ")) + "annotate-user" -> { + UserAnnotation.parse(values.joinToString(" "))?.let { + annotations.add(it) + } + } + } + values.clear() + } + keyNextValue = it + } else { + values.add(it) + } + } + + if (keyNextValue != null && values.isNotEmpty()) { + when (keyNextValue) { + "url" -> url = values.joinToString(" ") + "m" -> mimeType = values.joinToString(" ") + "blurhash" -> blurhash = values.joinToString(" ") + "dim" -> dim = Dimension.parse(values.joinToString(" ")) + "alt" -> alt = values.joinToString(" ") + "x" -> hash = values.joinToString(" ") + "size" -> size = values.joinToString(" ").toLongOrNull() + "fallback" -> fallback.add(values.joinToString(" ")) + "annotate-user" -> { + UserAnnotation.parse(values.joinToString(" "))?.let { + annotations.add(it) + } + } + } + values.clear() + keyNextValue = null + } + } else { + tagArray.forEach { + val parts = it.split(" ", limit = 2) + val key = parts[0] + val value = if (parts.size == 2) parts[1] else "" + + if (value.isNotBlank()) { + when (key) { + "url" -> url = value + "m" -> mimeType = value + "blurhash" -> blurhash = value + "dim" -> dim = Dimension.parse(value) + "alt" -> alt = value + "x" -> hash = value + "size" -> size = value.toLongOrNull() + "fallback" -> fallback.add(value) + "annotate-user" -> { + UserAnnotation.parse(value)?.let { + annotations.add(it) + } + } + } + } + } + } + + return url?.let { + PictureMeta(it, mimeType, blurhash, dim, alt, hash, size, fallback, annotations) + } + } + } +} + +class Dimension( + val width: Int, + val height: Int, +) { + fun aspectRatio() = width.toFloat() / height.toFloat() + + override fun toString() = "${width}x$height" + + companion object { + fun parse(dim: String): Dimension? { + if (dim == "0x0") return null + + val parts = dim.split("x") + if (parts.size != 2) return null + + return try { + val width = parts[0].toInt() + val height = parts[1].toInt() + + Dimension(width, height) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + } +} + +class UserAnnotation( + val pubkey: HexKey, + val x: Int, + val y: Int, +) { + companion object { + fun parse(value: String): UserAnnotation? { + val ann = value.split(":") + if (ann.size == 3) { + val x = ann[1].toIntOrNull() + val y = ann[2].toIntOrNull() + if (x != null && y != null) { + return UserAnnotation(ann[0], x, y) + } + } + + return null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt index 636fb5085..841edb19b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt @@ -46,7 +46,7 @@ class ProfileGalleryEntryEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) @@ -87,7 +87,7 @@ class ProfileGalleryEntryEvent( alt: String? = null, hash: String? = null, size: String? = null, - dimensions: String? = null, + dimensions: Dimension? = null, blurhash: String? = null, originalHash: String? = null, magnetURI: String? = null, @@ -109,7 +109,7 @@ class ProfileGalleryEntryEvent( alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), hash?.let { arrayOf(HASH, it) }, size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, + dimensions?.let { arrayOf(DIMENSION, it.toString()) }, blurhash?.let { arrayOf(BLUR_HASH, it) }, originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, magnetURI?.let { arrayOf(MAGNET_URI, it) }, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 0fc8e0ca0..cae5ba62f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -47,7 +47,7 @@ abstract class VideoEvent( fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)