diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 83082f576..eafee3d77 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) @@ -293,35 +295,3 @@ dependencies { debugImplementation libs.androidx.ui.test.manifest } -// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix -// NB: Android Studio can't find the imports; this does not affect the -// actual build since Gradle can find them just fine. -import com.android.tools.profgen.ArtProfileKt -import com.android.tools.profgen.ArtProfileSerializer -import com.android.tools.profgen.DexFile -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -project.afterEvaluate { - tasks.each { task -> - if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) { - task.doLast { - outputs.files.each { file -> - if (file.name.endsWith(".profm")) { - println("Sorting ${file} ...") - def version = ArtProfileSerializer.valueOf("METADATA_0_0_2") - def profile = ArtProfileKt.ArtProfile(file) - def keys = new ArrayList(profile.profileData.keySet()) - def sortedData = new LinkedHashMap() - Collections.sort keys, new DexFile.Companion() - keys.each { key -> sortedData[key] = profile.profileData[key] } - new FileOutputStream(file).with { - write(version.magicBytes$profgen) - write(version.versionBytes$profgen) - version.write$profgen(it, sortedData, "") - } - } - } - } - } - } -} 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..7ce523620 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 @@ -118,7 +119,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -480,10 +481,9 @@ class Account( @OptIn(ExperimentalCoroutinesApi::class) private val liveNotificationList: Flow by lazy { - defaultNotificationFollowList - .transformLatest { listName -> - emit(loadPeopleListFlowFromListName(listName)) - }.flattenMerge() + defaultNotificationFollowList.flatMapLatest { listName -> + loadPeopleListFlowFromListName(listName) + } } val liveNotificationFollowLists: StateFlow by lazy { @@ -493,10 +493,9 @@ class Account( @OptIn(ExperimentalCoroutinesApi::class) private val liveStoriesList: Flow by lazy { - defaultStoriesFollowList - .transformLatest { listName -> - emit(loadPeopleListFlowFromListName(listName)) - }.flattenMerge() + defaultStoriesFollowList.flatMapLatest { listName -> + loadPeopleListFlowFromListName(listName) + } } val liveStoriesFollowLists: StateFlow by lazy { @@ -506,10 +505,9 @@ class Account( @OptIn(ExperimentalCoroutinesApi::class) private val liveDiscoveryList: Flow by lazy { - defaultDiscoveryFollowList - .transformLatest { listName -> - emit(loadPeopleListFlowFromListName(listName)) - }.flattenMerge() + defaultDiscoveryFollowList.flatMapLatest { listName -> + loadPeopleListFlowFromListName(listName) + } } val liveDiscoveryFollowLists: StateFlow by lazy { @@ -2197,6 +2195,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/actions/NewPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index ad69a8bec..804e91422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -630,17 +630,21 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { @Composable private fun PollField(postViewModel: NewPostViewModel) { + val optionsList = postViewModel.pollOptions Column( modifier = Modifier.fillMaxWidth(), ) { - postViewModel.pollOptions.values.forEachIndexed { index, _ -> - NewPollOption(postViewModel, index) + optionsList.forEach { value -> + NewPollOption(postViewModel, value.key) } NewPollVoteValueRange(postViewModel) Button( - onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" }, + onClick = { + // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" + optionsList[optionsList.size] = "" + }, border = BorderStroke( 1.dp, 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 55dc95a68..eedf5298e 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 @@ -1278,10 +1278,26 @@ open class NewPostViewModel : ViewModel() { } fun removePollOption(optionIndex: Int) { - pollOptions.remove(optionIndex) + pollOptions.removeOrdered(optionIndex) saveDraft() } + private fun MutableMap.removeOrdered(index: Int) { + val keyList = keys + val elementList = values.toMutableList() + run stop@{ + for (i in index until elementList.size) { + val nextIndex = i + 1 + if (nextIndex == elementList.size) return@stop + elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } + } + } + elementList.removeLast() + val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } + this.clear() + this.putAll(newEntries) + } + fun updatePollOption( optionIndex: Int, text: String, 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/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 14eab0a69..f27b166ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -94,8 +94,8 @@ class HomeNewThreadFeedFilter( noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || - noteEvent is LongTextNoteEvent || - noteEvent is WikiNoteEvent || + (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || 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 250eca54f..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, @@ -168,6 +168,10 @@ fun DiscoverScreen( println("Discovery Start") NostrDiscoveryDataSource.start() } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Discovery Stop") + NostrDiscoveryDataSource.stop() + } } lifeCycleOwner.lifecycle.addObserver(observer) 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-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index cda4a8344..c10bf9dc2 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -14,6 +14,7 @@ No se pudo desencriptar el mensaje Imagen de grupo Contenido explícito + Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal @@ -35,6 +36,7 @@ Reportar suplantación de identidad Reportar contenido explícito Reportar comportamiento ilegal + Reportar malware Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones. @@ -44,11 +46,13 @@ Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. + Zaps Total de visualizaciones Impulsar Impulsada editada edición #%1$s + original Citar Bifurcar Proponer una edición @@ -89,6 +93,7 @@ No se pudo cargar la imagen Dirección del relé Publicaciones + Bytes Errores El número de errores de conexión en esta sesión Tus noticias @@ -195,6 +200,9 @@ Dirección de Nostr nunca ahora + h + m + d Desnudos Blasfemias / Discurso de odio Denuncia discurso de odio @@ -220,6 +228,7 @@ Autenticación fallida El sistema biométrico no pudo autenticar al propietario de este teléfono El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s + Error "Credo por %1$s" "Imagen del Badge para %1$s" Has recibido un nuevo Badge @@ -259,6 +268,7 @@ Suplantación de identidad malintencionada Desnudos o contenido gráfico Comportamiento ilegal + Malware Si bloqueas a un usuario, se ocultará su contenido en tu app. Tus notas todavía son visibles públicamente, incluso para las personas que bloquees. Los usuarios bloqueados aparecen en la pantalla \"Filtros de seguridad\". Reportar abuso @@ -296,6 +306,7 @@ Zap mínimo Zap máximo Consenso + (0–100)% Cerrar después de días No se puede votar @@ -326,9 +337,18 @@ El remitente y el destinatario pueden verse entre sí y leer el mensaje Anónimo El receptor y el público no saben quién envió el pago + No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos Dirección o @usuario de Lightning + Servidores multimedia + Configura tus servidores preferidos para subir contenido multimedia. + No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + Servidores multimedia integrados + Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. + Usar lista predeterminada + Agregar servidor multimedia + Eliminar servidor multimedia Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Configuración de Tor/Orbot @@ -336,8 +356,10 @@ ¿Desconectarse de tu Orbot/Tor? Los datos se transferirán de inmediato en la red normal + No Lista de seguidos Todos los seguidos + Global Lista de silenciados ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -355,6 +377,7 @@ Te notifica cuando llega un mensaje privado Zaps recibidos Te notifica cuando alguien te zapea + %1$s sats De %1$s por %1$s Notificar: @@ -374,6 +397,7 @@ Avisar cuando las publicaciones tengan reportes de tus seguidos Nuevo símbolo de reacción No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos. + Recaudación de zaps Añade una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. Cantidad objetivo en sats Zapraiser en %1$s. %2$s sats hasta la meta @@ -384,6 +408,7 @@ Se ha producido un error al intentar obtener información sobre el relé de %1$s Propietario Versión + Software Contacto NIP compatibles Tasas de admisión @@ -402,6 +427,7 @@ Etiquetas de evento máximas Longitud del contenido PoW mínima + Autenticación Pago Token de Cashu Canjear @@ -423,6 +449,7 @@ Mercado En vivo Comunidad + Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. Esta comunidad no tiene descripción. Habla con el propietario para agregar una. @@ -431,6 +458,7 @@ Configuración Siempre Solo Wi-Fi + WiFi sin medición Nunca Completo Simplificado @@ -449,6 +477,7 @@ Modo de interfaz Elegir el estilo de publicación Cargar imagen + Spammers Silenciado. Hacer clic para reactivar el sonido. Sonido activado. Hacer clic para silenciar. Buscar grabaciones locales y remotas @@ -496,6 +525,7 @@ Los votos se calculan según la cantidad de zaps. Puedes establecer una cantidad mínima para evitar spammers y una máxima para que los zappers grandes no puedan dominar la encuesta. Usa la misma cantidad en ambos campos para asegurarte de que cada voto tenga el mismo valor. Déjalos vacíos para aceptar cualquier cantidad. No se pudo enviar el zap Mensaje para el usuario + OK No se pudo llegar a %1$s: %2$s Error al ensamblar la url NIP-11 para %1$s: %2$s No se pudo contactar %1$s: %2$s @@ -507,6 +537,8 @@ Activo para: Inicio Mensajes directos + Chats + Global Búsqueda Dividir y reenviar zaps Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de enviártelos a ti. @@ -515,6 +547,7 @@ Lightning no está configurado El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats. Porcentaje + 25 Dividir zaps con Reenviar zaps a No se encontraron billeteras de Lightning @@ -567,6 +600,7 @@ Seleccionar una aplicación UnifiedPush Notificación push De aplicaciones UnifiedPush instaladas + Ninguno Desactiva las notificaciones push Usa la app %1$s Configuración de notificaciones push @@ -580,9 +614,11 @@ Hola, ¿esto todavía está disponible? Vender un artículo Título + iPhone 13 Estado Categoría Precio (en sats) + 1000 Ubicación Ciudad, estado, país. Nuevo @@ -616,6 +652,8 @@ El servidor no proporcionó una URL después de la carga No se pudo descargar el contenido cargado desde el servidor No se pudo preparar el archivo local para cargar: %1$s + Error al subir: %1$s + Error al eliminar: %1$s Editar borrador Iniciar sesión con código QR Ruta @@ -624,6 +662,7 @@ Descubrir Mensajes Notificaciones + Global Cortos Filtros de seguridad Nueva publicación @@ -634,6 +673,8 @@ Responder Impulsar o citar Me gusta + Zap + Cambiar reacciones rápidas Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -644,6 +685,7 @@ Factura de Bitcoin Cancelar factura de Bitcoin Cancelar la venta de un artículo + Recaudación de zaps Cancelar zapraiser Ubicación Eliminar ubicación @@ -694,7 +736,9 @@ Bifurcación de BIFURCACIÓN Repositorio Git: %1$s + Web: Clon: + OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar publicación @@ -722,4 +766,34 @@ Solicitando trabajo a DVM Solicitud de pago enviada, esperando confirmación de tu monedero Esperando a que DVM confirme el pago o envíe resultados + Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud. + No autorizado - El usuario no tiene credenciales de autenticación válidas + Pago requerido: el servidor requiere pago para completar la solicitud. + Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud. + No se encontró: el servidor no puede encontrar la dirección solicitada. + Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino. + No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud. + Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas. + Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor. + Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso. + Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá. + Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida. + Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple. + Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla. + URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. + Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite. + Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. + Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect. + Actualización requerida: el servidor se niega a procesar la solicitud utilizando el protocolo actual a menos que el cliente se actualice a un protocolo diferente. + Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud. + No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud. + Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host. + Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento. + Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta. + Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud. + La variante también negocia: el servidor tiene un error de configuración interno. + Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. + Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. + Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Agregar servidor NIP-96 diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index b5c3aee43..68e44875f 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -14,6 +14,7 @@ No se pudo desencriptar el mensaje Foto de grupo Contenido explícito + Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal @@ -35,6 +36,7 @@ Reportar suplantación de identidad Reportar contenido explícito Reportar comportamiento ilegal + Reportar malware Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones. @@ -44,11 +46,13 @@ Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. + Zaps Total de visualizaciones Impulsar Impulsada editada edición #%1$s + original Cita Bifurcar Proponer una edición @@ -89,6 +93,7 @@ Error al subir la imagen Dirección del relé Publicaciones + Bytes Errores El número de errores de conexión en esta sesión Tus noticias @@ -195,6 +200,9 @@ Dirección de Nostr nunca ahora + h + m + d Desnudez Groserías o lenguaje que incita al odio Reportar lenguaje que incita al odio @@ -220,6 +228,7 @@ Error de autenticación El sistema biométrico no pudo autenticar al propietario de este teléfono El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s + Error "Creado por %1$s" "Imagen de premio de insignia por %1$s" Recibiste un nuevo premio de insignia @@ -259,6 +268,7 @@ Suplantación de identidad malintencionada Desnudez o contenido gráfico Comportamiento ilegal + Malware Si bloqueas a un usuario, se ocultará su contenido en tu app. Tus notas todavía son visibles públicamente, incluso para las personas que bloquees. Los usuarios bloqueados aparecen en la pantalla \"Filtros de seguridad\". Reportar abuso @@ -296,6 +306,7 @@ Zap mínimo Zap máximo Consenso + (0–100)% Cerrar después de días No se puede votar @@ -326,9 +337,18 @@ El remitente y el destinatario pueden verse entre sí y leer el mensaje Anónimo El receptor y el público no saben quién envió el pago + No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos Dirección o @usuario de Lightning + Servidores multimedia + Configura tus servidores preferidos para subir contenido multimedia. + No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + Servidores multimedia integrados + Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. + Usar lista predeterminada + Agregar servidor multimedia + Eliminar servidor multimedia Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Configuración de Tor/Orbot @@ -336,8 +356,10 @@ ¿Desconectarse de tu Orbot/Tor? Los datos se transferirán de inmediato en la red normal + No Lista de seguidos Todos los seguidos + Global Lista de silenciados ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -355,6 +377,7 @@ Te notifica cuando llega un mensaje privado Zaps recibidos Te notifica cuando alguien te zapea + %1$s sats De %1$s por %1$s Notificar: @@ -374,6 +397,7 @@ Avisar cuando las publicaciones tengan reportes de tus seguidos Nuevo símbolo de reacción No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos. + Recaudación de zaps Agrega una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. Cantidad objetivo en sats Zapraiser en %1$s. %2$s sats hasta la meta @@ -384,6 +408,7 @@ Se produjo un error al intentar obtener información sobre el relé de %1$s Propietario Versión + Software Contacto NIP compatibles Tarifas de admisión @@ -402,6 +427,7 @@ Etiquetas de evento máximas Longitud del contenido PoW mínima + Autenticación Pago Token de Cashu Canjear @@ -423,6 +449,7 @@ Mercado En vivo Comunidad + Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. Esta comunidad no tiene descripción. Habla con el propietario para agregar una. @@ -431,6 +458,7 @@ Configuración Siempre Solo Wi-Fi + WiFi sin medición Nunca Completo Simplificado @@ -449,6 +477,7 @@ Modo de interfaz Elegir el estilo de publicación Cargar imagen + Spammers Silenciado. Hacer clic para reactivar el sonido. Sonido activado. Hacer clic para silenciar. Buscar grabaciones locales y remotas @@ -496,6 +525,7 @@ Los votos se calculan según la cantidad de zaps. Puedes establecer una cantidad mínima para evitar spammers y una máxima para que los zappers grandes no puedan dominar la encuesta. Usa la misma cantidad en ambos campos para asegurarte de que cada voto tenga el mismo valor. Déjalos vacíos para aceptar cualquier cantidad. No se pudo enviar el zap Mensaje para el usuario + OK No se pudo llegar a %1$s: %2$s Error al ensamblar la url NIP-11 para %1$s: %2$s No se pudo contactar %1$s: %2$s @@ -507,6 +537,8 @@ Activo para: Inicio Mensajes directos + Chats + Global Búsqueda Dividir y reenviar zaps Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti. @@ -515,6 +547,7 @@ Lightning no está configurado El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats. Porcentaje + 25 Dividir zaps con Reenviar zaps a No se encontraron billeteras de Lightning @@ -581,9 +614,11 @@ Hola, ¿esto todavía está disponible? Vender un artículo Título + iPhone 13 Estado Categoría Precio (en sats) + 1000 Ubicación Ciudad, estado, país. Nuevo @@ -617,6 +652,8 @@ El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor No se pudo preparar el archivo local para subir: %1$s + Error al subir: %1$s + Error al eliminar: %1$s Editar borrador Iniciar sesión con código QR Ruta @@ -625,6 +662,7 @@ Descubrir Mensajes Notificaciones + Global Cortos Filtros de seguridad Nueva publicación @@ -635,6 +673,8 @@ Responder Impulsar o citar Me gusta + Zap + Cambiar reacciones rápidas Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -645,6 +685,7 @@ Factura de Bitcoin Cancelar factura de Bitcoin Cancelar la venta de un artículo + Recaudación de zaps Cancelar zapraiser Ubicación Eliminar ubicación @@ -695,7 +736,9 @@ Bifurcación de BIFURCACIÓN Repositorio Git: %1$s + Web: Clon: + OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar publicación @@ -723,4 +766,34 @@ Solicitando trabajo a DVM Solicitud de pago enviada, esperando confirmación de tu billetera Esperando a que DVM confirme el pago o envíe resultados + Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud. + No autorizado: el usuario no tiene credenciales de autenticación válidas. + Pago requerido: el servidor requiere pago para completar la solicitud. + Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud. + No se encontró: el servidor no puede encontrar la dirección solicitada. + Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino. + No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud. + Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas. + Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor. + Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso. + Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá. + Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida. + Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple. + Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla. + URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. + Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite. + Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. + Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect. + Actualización requerida: el servidor se niega a procesar la solicitud utilizando el protocolo actual a menos que el cliente se actualice a un protocolo diferente. + Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud. + No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud. + Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host. + Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento. + Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta. + Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud. + La variante también negocia: el servidor tiene un error de configuración interno. + Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. + Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. + Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Agregar servidor NIP-96 diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index decf118f1..8d6c99acc 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -14,6 +14,7 @@ No se pudo desencriptar el mensaje Imagen del grupo Contenido explícito + Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal @@ -35,6 +36,7 @@ Reportar suplantación de identidad Reportar contenido explícito Reportar comportamiento ilegal + Reportar malware Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones. @@ -44,11 +46,13 @@ Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. + Zaps Total vistas Impulsar Impulsada editada edición #%1$s + original Cita Bifurcar Proponer una edición @@ -89,6 +93,7 @@ Error al subir la imagen Dirección del relé Publicaciones + Bytes Errores El número de errores de conexión en esta sesión Tus noticias @@ -195,6 +200,9 @@ Dirección de Nostr nunca ahora + h + m + d Desnudez Groserías o lenguaje que incita al odio Reportar lenguaje que incita al odio @@ -220,6 +228,7 @@ Error de autenticación El sistema biométrico no pudo autenticar al propietario de este teléfono El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s + Error "Creado por %1$s" "Imagen de premio de insignia por %1$s" Recibiste un nuevo premio de insignia @@ -259,6 +268,7 @@ Suplantación de identidad malintencionada Desnudez o contenido gráfico Comportamiento ilegal + Malware Si bloqueas a un usuario, se ocultará su contenido en tu app. Tus notas todavía son visibles públicamente, incluso para las personas que bloquees. Los usuarios bloqueados aparecen en la pantalla \"Filtros de seguridad\". Reportar abuso @@ -296,6 +306,7 @@ Zap mínimo Zap máximo Consenso + (0–100)% Cerrar después de días No se puede votar @@ -326,9 +337,18 @@ El remitente y el receptor pueden verse entre sí y leer el mensaje Anónimo El receptor y el público no saben quién envió el pago + No zap No hay rastro en Nostr, solo en Lightning Servidor de archivos Dirección o @usuario de Lightning + Servidores multimedia + Configura tus servidores preferidos para subir contenido multimedia. + No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓ + Servidores multimedia integrados + Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista. + Usar lista predeterminada + Agregar servidor multimedia + Eliminar servidor multimedia Tus relés (NIP-95) Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. Configuración de Tor/Orbot @@ -336,8 +356,10 @@ ¿Desconectarse de tu Orbot/Tor? Los datos se transferirán de inmediato en la red normal + No Lista de seguidos Todos los seguidos + Global Lista de silenciados ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -355,6 +377,7 @@ Te notifica cuando llega un mensaje privado Zaps recibidos Te notifica cuando alguien te zapea + %1$s sats De %1$s por %1$s Notificar: @@ -374,6 +397,7 @@ Avisar cuando las publicaciones tengan reportes de tus seguidos Nuevo símbolo de reacción No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos. + Recaudación de zaps Agrega una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. Cantidad objetivo en sats Zapraiser en %1$s. %2$s sats hasta la meta @@ -384,6 +408,7 @@ Se produjo un error al intentar obtener información sobre el relé de %1$s Propietario Versión + Software Contacto NIP compatibles Comisiones de admisión @@ -402,6 +427,7 @@ Etiquetas de evento máximas Longitud del contenido PoW mínima + Autenticación Pago Token de Cashu Canjear @@ -423,6 +449,7 @@ Mercado En vivo Comunidad + Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. Esta comunidad no tiene descripción. Habla con el propietario para agregar una. @@ -431,6 +458,7 @@ Configuración Siempre Solo Wi-Fi + WiFi sin medición Nunca Completo Simplificado @@ -449,6 +477,7 @@ Modo de interfaz Elegir el estilo de publicación Cargar imagen + Spammers Silenciado. Hacer clic para reactivar el sonido. Sonido activado. Hacer clic para silenciar. Buscar grabaciones locales y remotas @@ -496,6 +525,7 @@ Los votos se calculan según la cantidad de zaps. Puedes establecer una cantidad mínima para evitar spammers y una máxima para que los zappers grandes no puedan dominar la encuesta. Usa la misma cantidad en ambos campos para asegurarte de que cada voto tenga el mismo valor. Déjalos vacíos para aceptar cualquier cantidad. No se pudo enviar el zap Mensaje para el usuario + OK No se pudo llegar a %1$s: %2$s Error al ensamblar la url NIP-11 para %1$s: %2$s No se pudo contactar %1$s: %2$s @@ -507,6 +537,8 @@ Activo para: Inicio Mensajes directos + Chats + Global Búsqueda Dividir y reenviar zaps Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti. @@ -515,6 +547,7 @@ Lightning no está configurado El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats. Porcentaje + 25 Dividir zaps con Reenviar zaps a No se encontraron billeteras de Lightning @@ -581,9 +614,11 @@ Hola, ¿esto todavía está disponible? Vender un artículo Título + iPhone 13 Estado Categoría Precio (en sats) + 1000 Ubicación Ciudad, estado, país. Nuevo @@ -617,6 +652,8 @@ El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor No se pudo preparar el archivo local para subir: %1$s + Error al subir: %1$s + Error al eliminar: %1$s Editar borrador Iniciar sesión con código QR Ruta @@ -625,6 +662,7 @@ Descubrir Mensajes Notificaciones + Global Cortos Filtros de seguridad Nueva publicación @@ -635,6 +673,8 @@ Responder Impulsar o citar Me gusta + Zap + Cambiar reacciones rápidas Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -645,6 +685,7 @@ Factura de Bitcoin Cancelar factura de Bitcoin Cancelar la venta de un artículo + Recaudación de zaps Cancelar zapraiser Ubicación Eliminar ubicación @@ -695,7 +736,9 @@ Bifurcación de BIFURCACIÓN Repositorio Git: %1$s + Web: Clon: + OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar publicación @@ -723,4 +766,34 @@ Solicitando trabajo a DVM Solicitud de pago enviada, esperando confirmación de tu billetera Esperando a que DVM confirme el pago o envíe resultados + Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud. + No autorizado: el usuario no tiene credenciales de autenticación válidas. + Pago requerido: el servidor requiere pago para completar la solicitud. + Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud. + No se encontró: el servidor no puede encontrar la dirección solicitada. + Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino. + No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud. + Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas. + Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor. + Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso. + Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá. + Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida. + Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple. + Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla. + URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. + Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite. + Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. + Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect. + Actualización requerida: el servidor se niega a procesar la solicitud utilizando el protocolo actual a menos que el cliente se actualice a un protocolo diferente. + Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud. + No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud. + Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host. + Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento. + Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta. + Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud. + La variante también negocia: el servidor tiene un error de configuración interno. + Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud. + Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud. + Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red. + Agregar servidor NIP-96 diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index 4a5264570..bb90e91cb 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -36,6 +36,7 @@ Signaler une falsification d\'identité Signaler un contenu choquant Signaler un comportement illégal + Signaler un programme malveillant Connectez vous avec une clé privée pour pouvoir répondre Connectez vous avec une clé privée pour pouvoir booster des posts Connectez vous avec une clé privée pour pouvoir aimer des posts @@ -267,6 +268,7 @@ Usurpation d\'identité Nudité ou contenu graphique Comportement illégal + Programme malveillant Bloquer un utilisateur cachera son contenu dans votre application. Vos notes sont toujours visibles publiquement, y compris pour les personnes que vous bloquez. Les utilisateurs bloqués sont listés sur l\'écran Filtres de Sécurité. Signaler un Abus @@ -339,6 +341,14 @@ Aucune trace sur Nostr, seulement sur le Lightning Serveur fichier Adresse LN ou @Utilisateur + Serveurs média + Définissez vos serveurs de téléversement de média préférés. + Vous n\'avez pas de serveur média personnalisé. Vous pouvez utiliser la liste d\'Amethyst, ou en ajouter un ci-dessous + Serveurs média intégrés + Liste par défaut d\'Amethyst. Vous pouvez les ajouter individuellement ou ajouter la liste. + Utiliser la liste par défaut + Ajouter un serveur média + Supprimer le serveur média Vos relais (NIP-95) Les fichiers sont hébergés par vos relais. Nouveau NIP: vérifiez s\'ils sont supportés Configuration Tor/Orbot @@ -642,6 +652,8 @@ Le serveur n\'a pas fourni d\'URL après le téléversement Impossible de télécharger le média depuis le serveur Impossible de préparer le fichier local à téléverser: %1$s + Échec du téléversement: %1$s + Échec de la suppression : %1$s Modifier le brouillon Se connecter avec un QR Code Route @@ -754,4 +766,5 @@ Tâche demandée par DVM Demande de paiement envoyée, en attente de confirmation depuis votre portefeuille En attente que DVM confirme le paiement ou envoie les résultats + Ajouter un serveur NIP-96 diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 18fdc51fc..7887e7c73 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -36,6 +36,7 @@ पररूपण की सूचना दें अभद्र विषयवस्तु की सूचना दें अवैध बरताव की सूचना दें + दुष्क्रमक की सूचना दें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। उत्तर देने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को उद्धृत करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को चाहने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें @@ -267,6 +268,7 @@ दुर्भावनापूर्ण पररूपण नग्नता अथवा आपत्तिजनक विषयवस्तु अवैध बरताव + दुष्क्रमक उपयोगकर्ता को बाधित करने से उनका विषयवस्तु आपके लिए छिपाया जाएगा क्रमक में। आपके टीकाएँ सार्वजनिक रूप से दृश्य रहेंगे, उन जनों के लिए भी जिनहे आपने बाधित किया। बाधित उपयोगकर्ता सूचित हैं सुरक्षा छलनियाँ पटल पर। दुर्व्यवहार की सूचना दें @@ -339,6 +341,14 @@ नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर अभिलेख सेवासंगणक लै॰जाल पता अथवा @उपयोगकर्ता + प्रसारसंगणक + आपके प्रसारसंगणक आद्यताएँ स्थापित करें। + आपका कोई विशिष्ट प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा प्रसारसंगणक नीचे जोड सकते हैं ↓ + अन्तर्निहित प्रसारसंगणक + अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं। + मूलविकल्प सूची का प्रयोग करें + प्रसारसंगणक जोडें + प्रसारसंगणक मिटाएँ आपके पुनःप्रसारक (निप॰-९५) अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं टोर / ओर्बोट स्थापन @@ -785,4 +795,5 @@ स्मृतिस्थान का अभाव - सेवासंगणक में पर्याप्त स्मृतिस्थान उपलब्ध नहीं अनुरोध पर सफलतापूर्वक काम करने के लिए क्रमचक्र दृष्ट - सेवासंगणक को अनन्त क्रमचक्र का पता चला अनुरोध पर काम करते हुए जाल प्रमाणीकरण आवश्यक - जाल उपलब्ध होने के लिए ग्राहक का प्रमाणीकरण अनिवार्य + निप॰-९६ सेवासंगणक जोडें diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 3a4ad34ef..04529a94a 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -36,6 +36,7 @@ Zgłoś podszywanie się Zgłoś niedozwoloną zawartość Zgłoś antyspołeczne działania + Zgłoś złośliwe oprogramowanie Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc odpowiedzieć Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty @@ -267,6 +268,7 @@ Złośliwe podszywanie się Nagość lub obraźliwa grafika Antyspołeczne zachowanie + Złośliwe oprogramowanie Zablokowanie użytkownika ukryje jego zawartość w aplikacji. Twoje notatki są nadal widoczne publicznie, w tym dla osób, które blokujesz. Zablokowani użytkownicy są wymienieni na ekranie filtrów bezpieczeństwa. Zgłoś nadużycie @@ -339,6 +341,14 @@ Brak śladu w Nostr, tylko w Lightning Serwer Plików LnAdres lub @Użytkownik + Serwery Multimediów + Ustaw swoje preferowane serwery przesyłania multimediów. + Nie masz ustawionych własnych serwerów multimediów. Możesz użyć listy Amethyst\'a lub dodać jeden poniżej ↓ + Wbudowane serwery multimediów + Domyślna lista Amethyst-a. Możesz dodawać je pojedynczo lub dodać pełną listę. + Użyj domyślnej listy + Dodaj serwer multimediów + Usuń serwer multimediów Twoje transmitery (NIP-95) Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany Tor/Orbot - Ustawienia @@ -426,6 +436,8 @@ Skopiuj token Nie ustawiono adresu Lightning Skopiowano token do schowka + NA ŻYWO + OFFLINE Zakończony ZAPLANOWANE Transmisja wyłączona @@ -776,4 +788,5 @@ Niewystarczająca pojemność - serwer nie ma wystarczającej pojemność, aby pomyślnie przetworzyć żądanie Wykryta pętla - serwer wykrywa nieskończoną pętlę podczas przetwarzania żądania Wymagane uwierzytelnienie sieciowe - Klient musi być uwierzytelniony aby uzyskać dostęp do sieci + Dodaj serwer NIP-96 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/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 6a352b6e9..770058460 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -75,7 +75,7 @@ class Relay( private var afterEOSEPerSubscription = mutableMapOf() private val authResponse = mutableMapOf() - private val sendWhenReady = mutableListOf() + private val outboxCache = mutableMapOf() fun register(listener: Listener) { listeners = listeners.plus(listener) @@ -93,6 +93,15 @@ class Relay( // Sends everything. renewFilters() + sendOutbox() + } + } + + private fun sendOutbox() { + synchronized(outboxCache) { + outboxCache.values.forEach { + send(it) + } } } @@ -157,13 +166,6 @@ class Relay( // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") onConnected(this@Relay) - synchronized(sendWhenReady) { - sendWhenReady.forEach { - send(it) - } - sendWhenReady.clear() - } - listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } } @@ -307,15 +309,22 @@ class Relay( val success = msgArray[2].asBoolean() val message = if (msgArray.size() > 2) msgArray[3].asText() else "" + Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") + if (authResponse.containsKey(eventId)) { val wasAlreadyAuthenticated = authResponse.get(eventId) authResponse.put(eventId, success) if (wasAlreadyAuthenticated != true && success) { renewFilters() + sendOutbox() } } - Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") + if (outboxCache.contains(eventId) && !message.startsWith("auth-required")) { + synchronized(outboxCache) { + outboxCache.remove(eventId) + } + } if (!success) { RelayStats.newNotice(url, "Failed to receive $eventId: $message") @@ -325,7 +334,7 @@ class Relay( } "AUTH" -> listeners.forEach { - // Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}") + Log.w("Relay", "Relay onAuth $url, ${ msgArray[1].asText()}") it.onAuth(this@Relay, msgArray[1].asText()) } "NOTIFY" -> @@ -477,23 +486,7 @@ class Relay( sendAuth(signedEvent) } else { if (write) { - if (isConnected()) { - if (isReady) { - writeToSocket("""["EVENT",${signedEvent.toJson()}]""") - } else { - synchronized(sendWhenReady) { - sendWhenReady.add(signedEvent) - } - } - } else { - // sends all filters after connection is successful. - connectAndRun { - writeToSocket("""["EVENT",${signedEvent.toJson()}]""") - - // Sends everything. - renewFilters() - } - } + sendEvent(signedEvent) } } } @@ -504,21 +497,20 @@ class Relay( } private fun sendEvent(signedEvent: EventInterface) { + synchronized(outboxCache) { + outboxCache.put(signedEvent.id(), signedEvent) + } + if (isConnected()) { if (isReady) { writeToSocket("""["EVENT",${signedEvent.toJson()}]""") - } else { - synchronized(sendWhenReady) { - sendWhenReady.add(signedEvent) - } } } else { // sends all filters after connection is successful. connectAndRun { - writeToSocket("""["EVENT",${signedEvent.toJson()}]""") - // Sends everything. renewFilters() + sendOutbox() } } } diff --git a/benchmark/build.gradle b/benchmark/build.gradle index c56b90ca0..2f907d171 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -31,7 +31,7 @@ android { debug { // Since debuggable can"t be modified by gradle for library modules, // it must be done in a manifest - see src/androidTest/AndroidManifest.xml - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro" } release { diff --git a/build.gradle b/build.gradle index 306762d2b..ae88392b4 100644 --- a/build.gradle +++ b/build.gradle @@ -4,34 +4,47 @@ plugins { alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.jetbrainsKotlinJvm) apply false alias(libs.plugins.androidBenchmark) apply false - alias(libs.plugins.diffplugSpotless) apply false + alias(libs.plugins.diffplugSpotless) alias(libs.plugins.googleServices) apply false alias(libs.plugins.jetbrainsComposeCompiler) apply false } -subprojects { +allprojects { apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$layout.buildDirectory/**/*.kt") - ktlint("1.3.1") - licenseHeaderFile rootProject.file('spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " + if (project === rootProject) { + spotless { + predeclareDeps() } - - groovyGradle { - target '*.gradle' + spotlessPredeclare { + kotlin { + ktlint("1.3.1") + } } + } else { + spotless { + kotlin { + target '**/*.kt' + targetExclude("$layout.buildDirectory/**/*.kt") - afterEvaluate { - tasks.named("preBuild") { - dependsOn("spotlessApply") + ktlint("1.3.1") + licenseHeaderFile rootProject.file('spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " + } + + groovyGradle { + target '*.gradle' } } } } +subprojects { + afterEvaluate { + tasks.named("preBuild") { + dependsOn("spotlessApply") + } + } +} tasks.register('installGitHook', Copy) { from new File(rootProject.rootDir, 'git-hooks/pre-commit') 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/gradle.properties b/gradle.properties index cdc50ba11..55f63fdfc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,4 +24,4 @@ android.nonTransitiveRClass=true android.enableR8.fullMode=true android.nonFinalResIds=false -kotlin.daemon.jvmargs=-Xmx4096m \ No newline at end of file +kotlin.daemon.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=3g \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e5ba2eaa..e5f8dd1ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,8 +47,6 @@ zelory = "3.0.1" zoomable = "1.6.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" -material = "1.10.0" -materialVersion = "1.12.0" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -119,7 +117,6 @@ zelory-video-compressor = { group = "id.zelory", name = "compressor", version.re zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } -material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } 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) + } + } +}