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 f4cb70743..1995916c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -91,6 +91,7 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.ReportEvent @@ -2197,6 +2198,35 @@ class Account( } } + fun addToGallery( + idHex: String, + url: String, + relay: String?, + ) { + if (!isWriteable()) return + ProfileGalleryEntryEvent.create( + url = url, + eventid = idHex, + /*magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, */ + signer = signer, + ) { event -> + Client.send(event) + LocalCache.consume(event, null) + } + } + + fun removeFromGallery(note: Note) { + delete(note) + } + fun addBookmark( note: Note, isPrivate: Boolean, 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 5e605af24..c74002477 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RecommendRelayEvent import com.vitorpamplona.quartz.events.RelaySetEvent @@ -1668,6 +1669,26 @@ object LocalCache { refreshObservers(note) } + 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) + } + fun consume( event: FileStorageHeaderEvent, relay: Relay?, @@ -2529,6 +2550,7 @@ object LocalCache { } is FhirResourceEvent -> consume(event, relay) is FileHeaderEvent -> consume(event, relay) + is ProfileGalleryEntryEvent -> consume(event, relay) is FileServersEvent -> consume(event, relay) is FileStorageEvent -> consume(event, relay) is FileStorageHeaderEvent -> consume(event, relay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 055476c11..a5118c559 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -139,6 +139,8 @@ open class Note( var relays = listOf() private set + var associatedNote: Note? = null + var lastReactionsDownloadTime: Map = emptyMap() fun id() = Hex.decode(idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 01abf0ad7..50964cd07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -488,6 +488,7 @@ class UserLiveSet( val innerRelayInfo = UserBundledRefresherLiveData(u) val innerZaps = UserBundledRefresherLiveData(u) val innerBookmarks = UserBundledRefresherLiveData(u) + val innerGallery = UserBundledRefresherLiveData(u) val innerStatuses = UserBundledRefresherLiveData(u) // UI Observers line up here. @@ -500,6 +501,7 @@ class UserLiveSet( val relayInfo = innerRelayInfo.map { it } val zaps = innerZaps.map { it } val bookmarks = innerBookmarks.map { it } + val gallery = innerGallery.map { it } val statuses = innerStatuses.map { it } val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() @@ -518,6 +520,7 @@ class UserLiveSet( relayInfo.hasObservers() || zaps.hasObservers() || bookmarks.hasObservers() || + gallery.hasObservers() || statuses.hasObservers() || profilePictureChanges.hasObservers() || nip05Changes.hasObservers() || @@ -533,6 +536,7 @@ class UserLiveSet( innerRelayInfo.destroy() innerZaps.destroy() innerBookmarks.destroy() + innerGallery.destroy() innerStatuses.destroy() } } 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 f05299d2c..cdd60db09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent @@ -153,6 +154,20 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { ) } + fun createProfileGalleryFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + Filter( + kinds = + listOf(ProfileGalleryEntryEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1000, + ), + ) + } + fun createReceivedAwardsFilter() = user?.let { TypedFilter( @@ -173,6 +188,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { listOfNotNull( createUserInfoFilter(), createUserPostsFilter(), + createProfileGalleryFilter(), createFollowFilter(), createFollowersFilter(), createUserReceivedZapsFilter(), 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 463690343..363868d52 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 @@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -204,6 +205,7 @@ fun VideoView( title: String? = null, thumb: VideoThumb? = null, roundedCorner: Boolean, + gallery: Boolean = false, isFiniteHeight: Boolean, waveform: ImmutableList? = null, artworkUri: String? = null, @@ -247,6 +249,7 @@ fun VideoView( title = title, thumb = thumb, roundedCorner = roundedCorner, + gallery = gallery, isFiniteHeight = isFiniteHeight, waveform = waveform, artworkUri = artworkUri, @@ -324,6 +327,7 @@ fun VideoViewInner( title: String? = null, thumb: VideoThumb? = null, roundedCorner: Boolean, + gallery: Boolean = false, isFiniteHeight: Boolean, waveform: ImmutableList? = null, artworkUri: String? = null, @@ -348,6 +352,7 @@ fun VideoViewInner( controller = controller, thumbData = thumb, roundedCorner = roundedCorner, + gallery = gallery, isFiniteHeight = isFiniteHeight, nostrUriCallback = nostrUriCallback, waveform = waveform, @@ -695,6 +700,7 @@ private fun RenderVideoPlayer( controller: MediaController, thumbData: VideoThumb?, roundedCorner: Boolean, + gallery: Boolean = false, isFiniteHeight: Boolean, nostrUriCallback: String?, waveform: ImmutableList? = null, @@ -712,13 +718,18 @@ private fun RenderVideoPlayer( Box { val borders = MaterialTheme.colorScheme.imageModifier - + val bordersSquare = MaterialTheme.colorScheme.videoGalleryModifier val myModifier = remember(controller) { if (roundedCorner) { modifier.then( borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center), ) + } else if (gallery) { + Modifier + modifier.then( + bordersSquare.defaultMinSize(minHeight = 75.dp).align(Alignment.Center), + ) } else { modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center) } @@ -737,6 +748,7 @@ private fun RenderVideoPlayer( setBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb()) controllerAutoShow = false + useController = !gallery thumbData?.thumb?.let { defaultArtwork = it } hideController() resizeMode = @@ -745,72 +757,77 @@ private fun RenderVideoPlayer( } else { AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - controller.pause() - innerOnDialog(it) + if (!gallery) { + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + controller.pause() + innerOnDialog(it) + } } + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visible -> + controllerVisible.value = visible == View.VISIBLE + onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } + }, + ) } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } - }, - ) } }, ) waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } + if (!gallery) { + val startingMuteState = remember(controller) { controller.volume < 0.001 } - val startingMuteState = remember(controller) { controller.volume < 0.001 } + MuteButton( + controllerVisible, + startingMuteState, + Modifier.align(Alignment.TopEnd), + ) { mute: Boolean -> + // makes the new setting the default for new creations. + DEFAULT_MUTED_SETTING.value = mute - MuteButton( - controllerVisible, - startingMuteState, - Modifier.align(Alignment.TopEnd), - ) { mute: Boolean -> - // makes the new setting the default for new creations. - DEFAULT_MUTED_SETTING.value = mute - - // if the user unmutes a video and it's not the current playing, switches to that one. - if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - keepPlayingMutex = null - } - - controller.volume = if (mute) 0f else 1f - } - - KeepPlayingButton( - keepPlaying, - controllerVisible, - Modifier.align(Alignment.TopEnd).padding(end = Size55dp), - ) { newKeepPlaying: Boolean -> - // If something else is playing and the user marks this video to keep playing, stops the other - // one. - if (newKeepPlaying) { - if (keepPlayingMutex != null && keepPlayingMutex != controller) { + // if the user unmutes a video and it's not the current playing, switches to that one. + if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { keepPlayingMutex?.stop() keepPlayingMutex?.release() - } - keepPlayingMutex = controller - } else { - if (keepPlayingMutex == controller) { keepPlayingMutex = null } + + controller.volume = if (mute) 0f else 1f } - keepPlaying.value = newKeepPlaying - } + KeepPlayingButton( + keepPlaying, + controllerVisible, + Modifier.align(Alignment.TopEnd).padding(end = Size55dp), + ) { newKeepPlaying: Boolean -> + // If something else is playing and the user marks this video to keep playing, stops the other + // one. + if (newKeepPlaying) { + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + } + keepPlayingMutex = controller + } else { + if (keepPlayingMutex == controller) { + keepPlayingMutex = null + } + } - AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context -> - saveImage(videoUri, mimeType, context, accountViewModel) - } + keepPlaying.value = newKeepPlaying + } - AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle -> - ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle) + AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context -> + saveImage(videoUri, mimeType, context, accountViewModel) + } + + AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle -> + ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle) + } + } else { + controller.volume = 0f } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index d028c9ce8..445045dd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -269,7 +269,7 @@ private fun DialogContent( contentDescription = stringRes(R.string.quick_action_share), ) - ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) + ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) } val localContext = LocalContext.current 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 195cdac5f..3544d639e 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 @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -596,12 +597,14 @@ fun DisplayBlurHash( @Composable fun ShareImageAction( + accountViewModel: AccountViewModel, popupExpanded: MutableState, content: BaseMediaContent, onDismiss: () -> Unit, ) { if (content is MediaUrlContent) { ShareImageAction( + accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.url, postNostrUri = content.uri, @@ -609,6 +612,7 @@ fun ShareImageAction( ) } else if (content is MediaPreloadedContent) { ShareImageAction( + accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), postNostrUri = content.uri, @@ -620,6 +624,7 @@ fun ShareImageAction( @OptIn(ExperimentalPermissionsApi::class) @Composable fun ShareImageAction( + accountViewModel: AccountViewModel, popupExpanded: MutableState, videoUri: String?, postNostrUri: String?, @@ -650,6 +655,23 @@ fun ShareImageAction( }, ) } + + postNostrUri?.let { + DropdownMenuItem( + text = { Text(stringRes(R.string.add_media_to_gallery)) }, + onClick = { + if (videoUri != null) { + var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent + if (n19 != null) { + accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0]) // TODO Whole list or first? + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + } + } + + onDismiss() + }, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt new file mode 100644 index 000000000..5b84eeacf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt @@ -0,0 +1,92 @@ +/** + * 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.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent + +class UserProfileGalleryFeedFilter( + val user: User, + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery" + + override fun showHiddenKey(): Boolean = + account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, it -> + acceptableEvent(it, params, user) + } + + var sorted = sort(notes) + var finalnotes = setOf() + for (item in sorted) { + val note = (item.event as ProfileGalleryEntryEvent).event()?.let { LocalCache.checkGetOrCreateNote(it) } + if (note != null) { + note.associatedNote = item + finalnotes = finalnotes + note + } + } + + return finalnotes.toList() + } + + override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { acceptableEvent(it, params, user) } + } + + fun acceptableEvent( + it: Note, + params: FilterByListParams, + user: User, + ): Boolean { + val noteEvent = it.event + return ( + (it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) + ) && + params.match(noteEvent) && + account.isAcceptable(it) + } + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + userHex = account.userProfile().pubkeyHex, + selectedListName = account.defaultStoriesFollowList.value, + followLists = account.liveStoriesFollowLists.value, + hiddenUsers = account.flowHiddenUsers.value, + ) + + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 75af3272e..4f8e2c6d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -59,6 +59,7 @@ fun BlankNotePreview() { fun BlankNote( modifier: Modifier = Modifier, idHex: String? = null, + shortPreview: Boolean = false, ) { Column(modifier = modifier) { Row { @@ -75,7 +76,12 @@ fun BlankNote( horizontalArrangement = Arrangement.Center, ) { Text( - text = stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", + text = + if (shortPreview) { + stringRes(R.string.post_not_found_short) + } else { + stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else "" + }, modifier = Modifier.padding(30.dp), color = Color.Gray, textAlign = TextAlign.Center, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 2cc7a5c62..ccff7de2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -154,6 +154,40 @@ fun LongPressToQuickAction( } } +@Composable +fun LongPressToQuickActionGallery( + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (() -> Unit) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + + content { popupExpanded.value = true } + + if (popupExpanded.value) { + if (baseNote.author == accountViewModel.account.userProfile()) { + NoteQuickActionMenuGallery( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + accountViewModel = accountViewModel, + nav = {}, + ) + } + } +} + +@Composable +fun NoteQuickActionMenuGallery( + note: Note, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + DeleteFromGalleryDialog(note, accountViewModel) { + onDismiss() + } +} + @Composable fun NoteQuickActionMenu( note: Note, @@ -435,6 +469,169 @@ private fun RenderMainPopup( } } +@Composable +private fun RenderDeleteFromGalleryPopup( + accountViewModel: AccountViewModel, + note: Note, + showDeleteAlertDialog: MutableState, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) + val cardShape = RoundedCornerShape(5.dp) + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + val backgroundColor = + if (MaterialTheme.colorScheme.isLight) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryButtonBackground + } + + val showToast = { stringRes: Int -> + scope.launch { + Toast + .makeText( + context, + stringRes(context, stringRes), + Toast.LENGTH_SHORT, + ).show() + } + } + + val isOwnNote = accountViewModel.isLoggedUser(note.author) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + + Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { + Card( + modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + NoteQuickActionItem( + icon = Icons.Default.ContentCopy, + label = stringRes(R.string.quick_action_copy_text), + ) { + accountViewModel.decrypt(note) { + clipboardManager.setText(AnnotatedString(it)) + showToast(R.string.copied_note_text_to_clipboard) + } + + onDismiss() + } + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + Icons.Default.AlternateEmail, + stringRes(R.string.quick_action_copy_user_id), + ) { + note.author?.let { + scope.launch { + clipboardManager.setText(AnnotatedString(it.toNostrUri())) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } + } + } + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + Icons.Default.FormatQuote, + stringRes(R.string.quick_action_copy_note_id), + ) { + scope.launch { + clipboardManager.setText(AnnotatedString(note.toNostrUri())) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } + } + } + HorizontalDivider( + color = primaryLight, + ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isOwnNote) { + NoteQuickActionItem( + Icons.Default.Delete, + stringRes(R.string.quick_action_delete), + ) { + if (accountViewModel.hideDeleteRequestDialog) { + accountViewModel.delete(note) + onDismiss() + } else { + showDeleteAlertDialog.value = true + } + } + } else if (isFollowingUser) { + NoteQuickActionItem( + Icons.Default.PersonRemove, + stringRes(R.string.quick_action_unfollow), + ) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } + } else { + NoteQuickActionItem( + Icons.Default.PersonAdd, + stringRes(R.string.quick_action_follow), + ) { + accountViewModel.follow(note.author!!) + onDismiss() + } + } + + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringRes(R.string.broadcast), + ) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } + VerticalDivider(color = primaryLight) + if (isOwnNote && note.isDraft()) { + NoteQuickActionItem( + Icons.Default.Edit, + stringRes(R.string.edit_draft), + ) { + onDismiss() + } + } else { + NoteQuickActionItem( + icon = Icons.Default.Share, + label = stringRes(R.string.quick_action_share), + ) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(context, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser( + sendIntent, + stringRes(context, R.string.quick_action_share), + ) + ContextCompat.startActivity(context, shareIntent, null) + onDismiss() + } + } + } + } + } + } +} + @Composable fun NoteQuickActionItem( icon: ImageVector, @@ -462,6 +659,25 @@ fun NoteQuickActionItem( } } +@Composable +fun DeleteFromGalleryDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = stringRes(R.string.quick_action_request_deletion_gallery_title), + textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body), + buttonIcon = Icons.Default.Delete, + buttonText = stringRes(R.string.quick_action_delete_dialog_btn), + onClickDoOnce = { + accountViewModel.removefromMediaGallery(note) + onDismiss() + }, + onDismiss = onDismiss, + ) +} + @Composable fun DeleteAlertDialog( note: Note, @@ -612,3 +828,95 @@ fun QuickActionAlertDialog( }, ) } + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + buttonIcon: ImageVector, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = title, + textContent = textContent, + icon = { + Icon( + imageVector = buttonIcon, + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + buttonIconResource: Int, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = title, + textContent = textContent, + icon = { + Icon( + painter = painterResource(buttonIconResource), + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + icon: @Composable () -> Unit, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(textContent) }, + confirmButton = { + Row( + modifier = + Modifier + .padding(all = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = onClickDoOnce, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(Modifier.width(8.dp)) + Text(buttonText) + } + } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index 33db44e8b..1983a4ba3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -37,6 +37,7 @@ fun WatchNoteEvent( baseNote: Note, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + shortPreview: Boolean = false, onNoteEventFound: @Composable () -> Unit, ) { WatchNoteEvent( @@ -54,6 +55,7 @@ fun WatchNoteEvent( onLongClick = showPopup, ) }, + shortPreview = shortPreview, ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 080aca811..0f43d93c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -86,6 +86,7 @@ fun VideoDisplay( val description = event.content.ifBlank { null } ?: event.alt() val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() + val id = note.id() val mimeType = event.mimeType() mutableStateOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 94e80f342..3343d89d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter +import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter @@ -248,6 +249,20 @@ class NostrUserProfileReportFeedViewModel( } } +class NostrUserProfileGalleryFeedViewModel( + val user: User, + val account: Account, +) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel = + NostrUserProfileGalleryFeedViewModel(user, account) + as NostrUserProfileGalleryFeedViewModel + } +} + class NostrUserProfileBookmarksFeedViewModel( val user: User, val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index b47a2fec0..c44bafda9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -45,6 +45,7 @@ object ScrollStateKeys { const val DISCOVER_SCREEN = "Discover" val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" + val PROFILE_GALLERY = Route.Home.base + "ProfileGallery" val DRAFTS = Route.Home.base + "Drafts" 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 b1e5c4eef..52b00c91c 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 @@ -669,6 +669,18 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } } + fun addMediaToGallery( + hex: String, + url: String, + relay: String?, + ) { + viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay) } + } + + fun removefromMediaGallery(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) } + } + fun addPrivateBookmark(note: Note) { viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index a70369032..b239c5bd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -118,13 +118,6 @@ fun DiscoverScreen( ScrollStateKeys.DISCOVER_CONTENT, AppDefinitionEvent.KIND, ), - TabItem( - R.string.discover_marketplace, - discoveryMarketplaceFeedViewModel, - Route.Discover.base + "Marketplace", - ScrollStateKeys.DISCOVER_MARKETPLACE, - ClassifiedsEvent.KIND, - ), TabItem( R.string.discover_live, discoveryLiveFeedViewModel, @@ -139,6 +132,13 @@ fun DiscoverScreen( ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.KIND, ), + TabItem( + R.string.discover_marketplace, + discoveryMarketplaceFeedViewModel, + Route.Discover.base + "Marketplace", + ScrollStateKeys.DISCOVER_MARKETPLACE, + ClassifiedsEvent.KIND, + ), TabItem( R.string.discover_chat, discoveryChatFeedViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt new file mode 100644 index 000000000..fbf2af6a8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt @@ -0,0 +1,370 @@ +/** + * 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.screen.loggedIn + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport +import com.vitorpamplona.amethyst.ui.note.ClickableNote +import com.vitorpamplona.amethyst.ui.note.LongPressToQuickActionGallery +import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.FeedEmpty +import com.vitorpamplona.amethyst.ui.screen.FeedError +import com.vitorpamplona.amethyst.ui.screen.FeedState +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.amethyst.ui.screen.LoadingFeed +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent + +@Composable +fun RenderGalleryFeed( + viewModel: FeedViewModel, + routeForLastRead: String?, + forceEventKind: Int?, + listState: LazyGridState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + GalleryFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun GalleryFeedLoaded( + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyGridState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + + Row(defaultModifier) { + GalleryCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +fun GalleryCardCompose( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + forceEventKind: Int?, + isHiddenFeed: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) { + CheckHiddenFeedWatchBlockAndReport( + note = baseNote, + modifier = modifier, + ignoreAllBlocksAndReports = isHiddenFeed, + showHiddenWarning = false, + accountViewModel = accountViewModel, + nav = nav, + ) { canPreview -> + + if (baseNote.associatedNote != null) { + if (baseNote.associatedNote!!.event != null) { + val image = (baseNote.associatedNote!!.event as ProfileGalleryEntryEvent).url() + if (image != null) { + GalleryCard( + galleryNote = baseNote.associatedNote!!, + baseNote = baseNote, + image = image, + modifier = modifier, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } +} + +@Composable +fun GalleryCard( + galleryNote: Note, + baseNote: Note, + image: String, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + // baseNote.event?.let { Text(text = it.pubKey()) } + LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderChannelCard( + baseNote, + image, + modifier, + parentBackgroundColor, + accountViewModel, + showPopup, + nav, + ) + } +} + +@Composable +private fun CheckNewAndRenderChannelCard( + baseNote: Note, + image: String, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, +) { + val backgroundColor = + calculateBackgroundColor( + createdAt = baseNote.createdAt(), + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + ) + + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerGalleryCardBox(baseNote, image, accountViewModel, nav) + } +} + +@Composable +fun InnerGalleryCardBox( + baseNote: Note, + image: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderGalleryThumb(baseNote, image, accountViewModel, nav) + } + } +} + +@Immutable +data class GalleryThumb( + val id: String?, + val image: String?, + val title: String?, + // val price: Price?, +) + +@Composable +fun RenderGalleryThumb( + baseNote: Note, + image: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val card by + baseNote + .live() + .metadata + .map { + GalleryThumb( + id = "", + image = image, + title = "", + // noteEvent?.title(), + // price = noteEvent?.price(), + ) + }.distinctUntilChanged() + .observeAsState( + GalleryThumb( + id = "", + image = image, + title = "", + ), + ) + + InnerRenderGalleryThumb(card as GalleryThumb, baseNote, accountViewModel) +} + +@Preview +@Composable +fun RenderGalleryThumbPreview(accountViewModel: AccountViewModel) { + Surface(Modifier.size(200.dp)) { + InnerRenderGalleryThumb( + card = + GalleryThumb( + id = "", + image = null, + title = "Like New", + // price = Price("800000", "SATS", null), + ), + note = Note("hex"), + accountViewModel = accountViewModel, + ) + } +} + +@Composable +fun InnerRenderGalleryThumb( + card: GalleryThumb, + note: Note, + accountViewModel: AccountViewModel, +) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentAlignment = BottomStart, + ) { + card.image?.let { + if (isVideoUrl(it)) { + VideoView( + videoUri = it, + mimeType = null, + title = "", + authorName = note.author?.toBestDisplayName(), + roundedCorner = false, + gallery = true, + isFiniteHeight = false, + alwaysShowVideo = true, + accountViewModel = accountViewModel, + ) + } else { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + ?: run { DisplayGalleryAuthorBanner(note) } + } +} + +@Composable +fun DisplayGalleryAuthorBanner(note: Note) { + WatchAuthor(note) { + BannerImage( + it, + Modifier + .fillMaxSize() + .clip(QuoteBorder), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index dd231bbdb..056371ea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -148,13 +148,17 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewMod import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileGalleryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -294,6 +298,16 @@ fun PrepareViewModels( ), ) + val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserGalleryFeedViewModel", + factory = + NostrUserProfileGalleryFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", @@ -312,6 +326,7 @@ fun PrepareViewModels( appRecommendations, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel = accountViewModel, nav = nav, @@ -328,6 +343,7 @@ fun ProfileScreen( appRecommendations: NostrUserAppRecommendationsFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -372,6 +388,7 @@ fun ProfileScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -388,6 +405,7 @@ private fun RenderSurface( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -447,6 +465,7 @@ private fun RenderSurface( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -469,6 +488,7 @@ private fun RenderScreen( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -500,6 +520,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -518,6 +539,7 @@ private fun CreateAndRenderPages( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -532,13 +554,14 @@ private fun CreateAndRenderPages( when (page) { 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) - 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) - 3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) - 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) - 5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 6 -> TabFollowedTags(baseUser, accountViewModel, nav) - 7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 8 -> TabRelays(baseUser, accountViewModel, nav) + 2 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) + 3 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) + 4 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) + 5 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) + 6 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) + 7 -> TabFollowedTags(baseUser, accountViewModel, nav) + 8 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 9 -> TabRelays(baseUser, accountViewModel, nav) } } @@ -573,6 +596,7 @@ private fun CreateAndRenderTabs( listOf<@Composable (() -> Unit)?>( { Text(text = stringRes(R.string.notes)) }, { Text(text = stringRes(R.string.replies)) }, + { Text(text = stringRes(R.string.gallery)) }, { FollowTabHeader(baseUser) }, { FollowersTabHeader(baseUser) }, { ZapTabHeader(baseUser) }, @@ -1534,6 +1558,77 @@ fun TabNotesConversations( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TabGallery( + feedViewModel: NostrUserProfileGalleryFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + // Column(Modifier.fillMaxHeight()) { + + RefresheableBox(feedViewModel, true) { + SaveableGridFeedState(feedViewModel, scrollStateKey = ScrollStateKeys.PROFILE_GALLERY) { listState -> + RenderGalleryFeed( + feedViewModel, + null, + 0, + listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + // } +} + +/*@Composable +fun Gallery( + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchFollowChanges(baseUser, feedViewModel) + + Column(Modifier.fillMaxHeight()) { + Column { + baseUser.latestGalleryList?.let { + // val note2 = getOrCreateAddressableNoteInternal(aTag) + val note = LocalCache.getOrCreateAddressableNote(it.address()) + note.event = it + var notes = listOf() + for (tag in note.event?.tags()!!) { + if (tag.size > 2) { + if (tag[0] == "g") { + // TODO get the node by id on main thread. LoadNote does nothing. + val thumb = + GalleryThumb( + baseNote = note, + id = tag[2], + // TODO use the original note once it's loaded baseNote = basenote, + image = tag[1], + title = null, + ) + notes = notes + thumb + // } + } + } + ProfileGallery( + baseNotes = notes, + modifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } +} */ + @Composable fun TabFollowedTags( baseUser: User, @@ -1545,7 +1640,11 @@ fun TabFollowedTags( baseUser.latestContactList?.unverifiedFollowTagSet() } - Column(Modifier.fillMaxHeight().padding(vertical = 0.dp)) { + Column( + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp), + ) { items?.let { LazyColumn { itemsIndexed(items) { index, hashtag -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f8c89983f..5be0b6d44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -56,6 +56,7 @@ val TabRowHeight = Modifier val SmallBorder = RoundedCornerShape(7.dp) val SmallishBorder = RoundedCornerShape(9.dp) val QuoteBorder = RoundedCornerShape(15.dp) + val ButtonBorder = RoundedCornerShape(20.dp) val EditFieldBorder = RoundedCornerShape(25.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index ef220986b..4fedca007 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView @@ -124,6 +125,18 @@ val LightImageModifier = .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) +val DarkVideoModifier = + Modifier + .fillMaxWidth() + .clip(shape = RectangleShape) + .border(1.dp, DarkSubtleBorder, RectangleShape) + +val LightVideoModifier = + Modifier + .fillMaxWidth() + .clip(shape = RectangleShape) + .border(1.dp, LightSubtleBorder, RectangleShape) + val DarkProfile35dpModifier = Modifier .size(Size35dp) @@ -148,6 +161,20 @@ val LightReplyBorderModifier = .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) +val DarkVideoBorderModifier = + Modifier + .padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = RectangleShape) + .border(1.dp, DarkSubtleBorder, RectangleShape) + +val LightVideoBorderModifier = + Modifier + .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .fillMaxWidth() + .clip(shape = RectangleShape) + .border(1.dp, LightSubtleBorder, RectangleShape) + val DarkInnerPostBorderModifier = Modifier .padding(vertical = 5.dp) @@ -356,6 +383,9 @@ val ColorScheme.markdownStyle: RichTextStyle val ColorScheme.imageModifier: Modifier get() = if (isLight) LightImageModifier else DarkImageModifier +val ColorScheme.videoGalleryModifier: Modifier + get() = if (isLight) LightVideoModifier else DarkVideoModifier + val ColorScheme.profile35dpModifier: Modifier get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 11610d162..c0072311c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ This post was hidden because it mentions your hidden users or words Post was muted or reported by Event is loading or can\'t be found in your relay list + 👀 Channel Image Referenced event not found Could not decrypt the message @@ -135,6 +136,7 @@ Conversations Notes Replies + Gallery "Follows" "Reports" More Options @@ -271,6 +273,8 @@ Delete Unfollow Follow + Delete from Gallery + Remove this media from your Gallery, you can readd it later Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. Block @@ -598,6 +602,7 @@ Share or Save Copy URL to clipboard Copy Note ID to clipboard + Add Media to Gallery Created at Rules 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 2db6e28db..28ee73ee7 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 @@ -38,6 +38,7 @@ abstract class MediaUrlContent( dim: String? = null, blurhash: String? = null, val uri: String? = null, + val id: String? = null, val mimeType: String? = null, ) : BaseMediaContent(description, dim, blurhash) @@ -49,6 +50,7 @@ class MediaUrlImage( blurhash: String? = null, dim: String? = null, uri: String? = null, + id: String? = null, val contentWarning: String? = null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -60,6 +62,7 @@ class MediaUrlVideo( hash: String? = null, dim: String? = null, uri: String? = null, + id: String? = null, val artworkUri: String? = null, val authorName: String? = null, blurhash: String? = null, @@ -76,6 +79,7 @@ abstract class MediaPreloadedContent( dim: String? = null, blurhash: String? = null, val uri: String, + val id: String? = null, ) : BaseMediaContent(description, dim, blurhash) { fun localFileExists() = localFile != null && localFile.exists() } 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 d8e071057..288ae12e1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -85,6 +85,7 @@ class EventFactory { EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.KIND -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt new file mode 100644 index 000000000..808155184 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt @@ -0,0 +1,169 @@ +/** + * 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 + +@Immutable +class GalleryListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 10011 + const val ALT = "Profile Gallery" + const val GALLERYTAGNAME = "url" + + fun addEvent( + earlierVersion: GalleryListEvent?, + eventId: HexKey, + url: String, + relay: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) = addTag(earlierVersion, GALLERYTAGNAME, eventId, url, relay, signer, createdAt, onReady) + + fun addTag( + earlierVersion: GalleryListEvent?, + tagName: String, + eventid: HexKey, + url: String, + relay: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) { + var tags = arrayOf(tagName, url, eventid) + if (relay != null) { + tags + relay + } + + add( + earlierVersion, + arrayOf(tags), + signer, + createdAt, + onReady, + ) + } + + fun add( + earlierVersion: GalleryListEvent?, + listNewTags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) { + create( + content = earlierVersion?.content ?: "", + tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun removeEvent( + earlierVersion: GalleryListEvent, + eventId: HexKey, + url: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) = removeTag(earlierVersion, GALLERYTAGNAME, eventId, url, signer, createdAt, onReady) + + fun removeReplaceable( + earlierVersion: GalleryListEvent, + aTag: ATag, + url: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) = removeTag(earlierVersion, GALLERYTAGNAME, aTag.toTag(), url, signer, createdAt, onReady) + + private fun removeTag( + earlierVersion: GalleryListEvent, + tagName: String, + eventid: HexKey, + url: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventid) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GalleryListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } + + @Immutable + data class GalleryUrl( + val url: String, + val id: String, + val relay: String?, + ) { + fun encode(): String = ":$url:$id:$relay" + + companion object { + fun decode(encodedGallerySetup: String): GalleryUrl? { + val galleryParts = encodedGallerySetup.split(":", limit = 3) + return if (galleryParts.size > 3) { + GalleryUrl(galleryParts[1], galleryParts[2], galleryParts[3]) + } else { + 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 new file mode 100644 index 000000000..2a04bfe38 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt @@ -0,0 +1,130 @@ +/** + * 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.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ProfileGalleryEntryEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + 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 encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == 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) + + 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 } + + fun event() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun hasEvent() = tags.any { it.size > 1 && it[0] == "e" } + + fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == MIME_TYPE && mimeTypes.contains(it[1]) } + + companion object { + const val KIND = 1163 + const val ALT_DESCRIPTION = "Profile Gallery Entry" + + const val URL = "url" + const val ENCRYPTION_KEY = "aes-256-gcm" + const val MIME_TYPE = "m" + const val FILE_SIZE = "size" + const val DIMENSION = "dim" + const val HASH = "x" + const val MAGNET_URI = "magnet" + const val TORRENT_INFOHASH = "i" + const val BLUR_HASH = "blurhash" + const val ORIGINAL_HASH = "ox" + const val ALT = "alt" + + fun create( + url: String, + eventid: String? = null, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ProfileGalleryEntryEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(URL, url), + eventid?.let { arrayOf("e", it) }, + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, + 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) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + + val content = alt ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +}