Merge branch 'main' into base64

This commit is contained in:
greenart7c3 2024-07-11 10:42:16 -03:00 committed by GitHub
commit c57c190344
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1779 additions and 170 deletions

View File

@ -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, "")
}
}
}
}
}
}
}

View File

@ -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<ListNameNotePair> by lazy {
defaultNotificationFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultNotificationFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
@ -493,10 +493,9 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
private val liveStoriesList: Flow<ListNameNotePair> by lazy {
defaultStoriesFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultStoriesFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
@ -506,10 +505,9 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy {
defaultDiscoveryFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultDiscoveryFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> 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,

View File

@ -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)

View File

@ -139,6 +139,8 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)

View File

@ -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()
}
}

View File

@ -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(),

View File

@ -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,

View File

@ -1278,10 +1278,26 @@ open class NewPostViewModel : ViewModel() {
}
fun removePollOption(optionIndex: Int) {
pollOptions.remove(optionIndex)
pollOptions.removeOrdered(optionIndex)
saveDraft()
}
private fun MutableMap<Int, String>.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,

View File

@ -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<Int>? = 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<Int>? = 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<Int>? = 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
}
}
}

View File

@ -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

View File

@ -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<Boolean>,
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<Boolean>,
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()
},
)
}
}
}

View File

@ -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 ||

View File

@ -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<Note>() {
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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, params, user)
}
var sorted = sort(notes)
var finalnotes = setOf<Note>()
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<Note>): Set<Note> = innerApplyFilter(collection)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}

View File

@ -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,

View File

@ -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<Boolean>,
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)
}
}
}
},
)
}

View File

@ -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,
)
}
},

View File

@ -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<BaseMediaContent>(

View File

@ -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 <NostrUserProfileGalleryFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileGalleryFeedViewModel>): NostrUserProfileGalleryFeedViewModel =
NostrUserProfileGalleryFeedViewModel(user, account)
as NostrUserProfileGalleryFeedViewModel
}
}
class NostrUserProfileBookmarksFeedViewModel(
val user: User,
val account: Account,

View File

@ -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"

View File

@ -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) }
}

View File

@ -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)

View File

@ -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<Color>? = 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<Color>? = 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<Color>? = 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),
)
}
}

View File

@ -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<GalleryThumb>()
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 ->

View File

@ -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)

View File

@ -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

View File

@ -14,6 +14,7 @@
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
<string name="group_picture">Imagen de grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
@ -35,6 +36,7 @@
<string name="report_impersonation">Reportar suplantación de identidad</string>
<string name="report_explicit_content">Reportar contenido explícito</string>
<string name="report_illegal_behaviour">Reportar comportamiento ilegal</string>
<string name="report_malware">Reportar malware</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones.</string>
<string name="login_with_a_private_key_to_like_posts">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.</string>
@ -44,11 +46,13 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">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.</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">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.</string>
<string name="login_with_a_private_key_to_be_able_to_show_word">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.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Total de visualizaciones</string>
<string name="boost">Impulsar</string>
<string name="boosted">Impulsada</string>
<string name="edited">editada</string>
<string name="edited_number">edición #%1$s</string>
<string name="original">original</string>
<string name="quote">Citar</string>
<string name="fork">Bifurcar</string>
<string name="propose_an_edit">Proponer una edición</string>
@ -89,6 +93,7 @@
<string name="failed_to_upload_the_image">No se pudo cargar la imagen</string>
<string name="relay_address">Dirección del relé</string>
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
@ -195,6 +200,9 @@
<string name="nip_05">Dirección de Nostr</string>
<string name="never">nunca</string>
<string name="now">ahora</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Desnudos</string>
<string name="profanity_hateful_speech">Blasfemias / Discurso de odio</string>
<string name="report_hateful_speech">Denuncia discurso de odio</string>
@ -220,6 +228,7 @@
<string name="biometric_authentication_failed">Autenticación fallida</string>
<string name="biometric_authentication_failed_explainer">El sistema biométrico no pudo autenticar al propietario de este teléfono</string>
<string name="biometric_authentication_failed_explainer_with_error">El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s</string>
<string name="biometric_error">Error</string>
<string name="badge_created_by">"Credo por %1$s"</string>
<string name="badge_award_image_for">"Imagen del Badge para %1$s"</string>
<string name="new_badge_award_notif">Has recibido un nuevo Badge</string>
@ -259,6 +268,7 @@
<string name="report_dialog_impersonation">Suplantación de identidad malintencionada</string>
<string name="report_dialog_nudity">Desnudos o contenido gráfico</string>
<string name="report_dialog_illegal">Comportamiento ilegal</string>
<string name="report_dialog_malware">Malware</string>
<string name="report_dialog_blocking_a_user">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\".</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquear y ocultar usuario]]></string>
<string name="report_dialog_report_btn">Reportar abuso</string>
@ -296,6 +306,7 @@
<string name="poll_zap_value_min">Zap mínimo</string>
<string name="poll_zap_value_max">Zap máximo</string>
<string name="poll_consensus_threshold">Consenso</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Cerrar después de</string>
<string name="poll_closing_time_days">días</string>
<string name="poll_unable_to_vote">No se puede votar</string>
@ -326,9 +337,18 @@
<string name="zap_type_private_explainer">El remitente y el destinatario pueden verse entre sí y leer el mensaje</string>
<string name="zap_type_anonymous">Anónimo</string>
<string name="zap_type_anonymous_explainer">El receptor y el público no saben quién envió el pago</string>
<string name="zap_type_nonzap">No zap</string>
<string name="zap_type_nonzap_explainer">No hay rastro en Nostr, solo en Lightning</string>
<string name="file_server">Servidor de archivos</string>
<string name="zap_forward_lnAddress">Dirección o @usuario de Lightning</string>
<string name="media_servers">Servidores multimedia</string>
<string name="set_preferred_media_servers">Configura tus servidores preferidos para subir contenido multimedia.</string>
<string name="no_media_server_message">No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓</string>
<string name="built_in_media_servers_title">Servidores multimedia integrados</string>
<string name="built_in_servers_description">Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista.</string>
<string name="use_default_servers">Usar lista predeterminada</string>
<string name="add_media_server">Agregar servidor multimedia</string>
<string name="delete_media_server">Eliminar servidor multimedia</string>
<string name="upload_server_relays_nip95">Tus relés (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles.</string>
<string name="connect_via_tor_short">Configuración de Tor/Orbot</string>
@ -336,8 +356,10 @@
<string name="do_you_really_want_to_disable_tor_title">¿Desconectarse de tu Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Los datos se transferirán de inmediato en la red normal</string>
<string name="yes"></string>
<string name="no">No</string>
<string name="follow_list_selection">Lista de seguidos</string>
<string name="follow_list_kind3follows">Todos los seguidos</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="app_notification_dms_channel_description">Te notifica cuando llega un mensaje privado</string>
<string name="app_notification_zaps_channel_name">Zaps recibidos</string>
<string name="app_notification_zaps_channel_description">Te notifica cuando alguien te zapea</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">De %1$s</string>
<string name="app_notification_zaps_channel_message_for">por %1$s</string>
<string name="reply_notify">Notificar: </string>
@ -374,6 +397,7 @@
<string name="warn_when_posts_have_reports_from_your_follows">Avisar cuando las publicaciones tengan reportes de tus seguidos</string>
<string name="new_reaction_symbol">Nuevo símbolo de reacción</string>
<string name="no_reaction_type_setup_long_press_to_change">No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos.</string>
<string name="zapraiser">Recaudación de zaps</string>
<string name="zapraiser_explainer">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.</string>
<string name="zapraiser_target_amount_in_sats">Cantidad objetivo en sats</string>
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
@ -384,6 +408,7 @@
<string name="an_error_occurred_trying_to_get_relay_information">Se ha producido un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
<string name="software">Software</string>
<string name="contact">Contacto</string>
<string name="supports">NIP compatibles</string>
<string name="admission_fees">Tasas de admisión</string>
@ -402,6 +427,7 @@
<string name="maximum_event_tags">Etiquetas de evento máximas</string>
<string name="content_length">Longitud del contenido</string>
<string name="minimum_pow">PoW mínima</string>
<string name="auth">Autenticación</string>
<string name="payment">Pago</string>
<string name="cashu">Token de Cashu</string>
<string name="cashu_redeem">Canjear</string>
@ -423,6 +449,7 @@
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Publicaciones aprobadas</string>
<string name="groups_no_descriptor">Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una.</string>
<string name="community_no_descriptor">Esta comunidad no tiene descripción. Habla con el propietario para agregar una.</string>
@ -431,6 +458,7 @@
<string name="settings">Configuración</string>
<string name="connectivity_type_always">Siempre</string>
<string name="connectivity_type_wifi_only">Solo Wi-Fi</string>
<string name="connectivity_type_unmetered_wifi_only">WiFi sin medición</string>
<string name="connectivity_type_never">Nunca</string>
<string name="ui_feature_set_type_complete">Completo</string>
<string name="ui_feature_set_type_simplified">Simplificado</string>
@ -449,6 +477,7 @@
<string name="ui_style">Modo de interfaz</string>
<string name="ui_style_description">Elegir el estilo de publicación</string>
<string name="load_image">Cargar imagen</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Silenciado. Hacer clic para reactivar el sonido.</string>
<string name="mute_button">Sonido activado. Hacer clic para silenciar.</string>
<string name="search_button">Buscar grabaciones locales y remotas</string>
@ -496,6 +525,7 @@
<string name="poll_zap_value_min_max_explainer">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.</string>
<string name="error_dialog_zap_error">No se pudo enviar el zap</string>
<string name="error_dialog_talk_to_user">Mensaje para el usuario</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">No se pudo llegar a %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_assemble_url">Error al ensamblar la url NIP-11 para %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_reach_server">No se pudo contactar %1$s: %2$s</string>
@ -507,6 +537,8 @@
<string name="active_for">Activo para: </string>
<string name="active_for_home">Inicio</string>
<string name="active_for_msg">Mensajes directos</string>
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string>
<string name="active_for_search">Búsqueda</string>
<string name="zap_split_title">Dividir y reenviar zaps</string>
<string name="zap_split_explainer">Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de enviártelos a ti.</string>
@ -515,6 +547,7 @@
<string name="missing_lud16">Lightning no está configurado</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats.</string>
<string name="zap_split_weight">Porcentaje</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Dividir zaps con</string>
<string name="forwarding_zaps_to">Reenviar zaps a</string>
<string name="lightning_wallets_not_found2">No se encontraron billeteras de Lightning</string>
@ -567,6 +600,7 @@
<string name="select_push_server">Seleccionar una aplicación UnifiedPush</string>
<string name="push_server_title">Notificación push</string>
<string name="push_server_explainer">De aplicaciones UnifiedPush instaladas</string>
<string name="push_server_none">Ninguno</string>
<string name="push_server_none_explainer">Desactiva las notificaciones push</string>
<string name="push_server_uses_app_explainer">Usa la app %1$s</string>
<string name="push_server_install_app">Configuración de notificaciones push</string>
@ -580,9 +614,11 @@
<string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string>
<string name="classifieds">Vender un artículo</string>
<string name="classifieds_title">Título</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Estado</string>
<string name="classifieds_category">Categoría</string>
<string name="classifieds_price">Precio (en sats)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Ubicación</string>
<string name="classifieds_location_placeholder">Ciudad, estado, país.</string>
<string name="classifieds_condition_new">Nuevo</string>
@ -616,6 +652,8 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la carga</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido cargado desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para cargar: %1$s</string>
<string name="failed_to_upload_with_message">Error al subir: %1$s</string>
<string name="failed_to_delete_with_message">Error al eliminar: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@ -624,6 +662,7 @@
<string name="route_discover">Descubrir</string>
<string name="route_messages">Mensajes</string>
<string name="route_notifications">Notificaciones</string>
<string name="route_global">Global</string>
<string name="route_video">Cortos</string>
<string name="route_security_filters">Filtros de seguridad</string>
<string name="new_post">Nueva publicación</string>
@ -634,6 +673,8 @@
<string name="reply_description">Responder</string>
<string name="boost_or_quote_description">Impulsar o citar</string>
<string name="like_description">Me gusta</string>
<string name="zap_description">Zap</string>
<string name="change_reaction">Cambiar reacciones rápidas</string>
<string name="profile_image_of_user">Imagen de perfil de %1$s</string>
<string name="relay_info">Relé %1$s</string>
<string name="expand_relay_list">Ampliar lista de relés</string>
@ -644,6 +685,7 @@
<string name="add_bitcoin_invoice">Factura de Bitcoin</string>
<string name="cancel_bitcoin_invoice">Cancelar factura de Bitcoin</string>
<string name="cancel_classifieds">Cancelar la venta de un artículo</string>
<string name="add_zapraiser">Recaudación de zaps</string>
<string name="cancel_zapraiser">Cancelar zapraiser</string>
<string name="add_location">Ubicación</string>
<string name="remove_location">Eliminar ubicación</string>
@ -694,7 +736,9 @@
<string name="forked_from">Bifurcación de</string>
<string name="forked_tag">BIFURCACIÓN</string>
<string name="git_repository">Repositorio Git: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Clon:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Prueba de marca de tiempo</string>
<string name="ots_info_description">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.</string>
<string name="edit_post">Editar publicación</string>
@ -722,4 +766,34 @@
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu monedero</string>
<string name="dvm_waiting_to_confirm_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
<string name="http_status_400">Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud.</string>
<string name="http_status_401">No autorizado - El usuario no tiene credenciales de autenticación válidas</string>
<string name="http_status_402">Pago requerido: el servidor requiere pago para completar la solicitud.</string>
<string name="http_status_403">Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud.</string>
<string name="http_status_404">No se encontró: el servidor no puede encontrar la dirección solicitada.</string>
<string name="http_status_405">Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino.</string>
<string name="http_status_406">No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud.</string>
<string name="http_status_407">Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas.</string>
<string name="http_status_408">Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor.</string>
<string name="http_status_409">Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso.</string>
<string name="http_status_410">Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá.</string>
<string name="http_status_411">Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida.</string>
<string name="http_status_412">Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple.</string>
<string name="http_status_413">Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla.</string>
<string name="http_status_414">URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. </string>
<string name="http_status_415">Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite.</string>
<string name="http_status_416">Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. </string>
<string name="http_status_417">Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect.</string>
<string name="http_status_426">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.</string>
<string name="http_status_500">Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud.</string>
<string name="http_status_501">No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud.</string>
<string name="http_status_502">Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host.</string>
<string name="http_status_503">Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento.</string>
<string name="http_status_504">Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta.</string>
<string name="http_status_505">Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud.</string>
<string name="http_status_506">La variante también negocia: el servidor tiene un error de configuración interno.</string>
<string name="http_status_507">Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud.</string>
<string name="http_status_508">Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud.</string>
<string name="http_status_511">Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red.</string>
<string name="add_a_nip96_server">Agregar servidor NIP-96</string>
</resources>

View File

@ -14,6 +14,7 @@
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
<string name="group_picture">Foto de grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
@ -35,6 +36,7 @@
<string name="report_impersonation">Reportar suplantación de identidad</string>
<string name="report_explicit_content">Reportar contenido explícito</string>
<string name="report_illegal_behaviour">Reportar comportamiento ilegal</string>
<string name="report_malware">Reportar malware</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones</string>
<string name="login_with_a_private_key_to_like_posts">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.</string>
@ -44,11 +46,13 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">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.</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">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.</string>
<string name="login_with_a_private_key_to_be_able_to_show_word">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.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Total de visualizaciones</string>
<string name="boost">Impulsar</string>
<string name="boosted">Impulsada</string>
<string name="edited">editada</string>
<string name="edited_number">edición #%1$s</string>
<string name="original">original</string>
<string name="quote">Cita</string>
<string name="fork">Bifurcar</string>
<string name="propose_an_edit">Proponer una edición</string>
@ -89,6 +93,7 @@
<string name="failed_to_upload_the_image">Error al subir la imagen</string>
<string name="relay_address">Dirección del relé</string>
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
@ -195,6 +200,9 @@
<string name="nip_05">Dirección de Nostr</string>
<string name="never">nunca</string>
<string name="now">ahora</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Desnudez</string>
<string name="profanity_hateful_speech">Groserías o lenguaje que incita al odio</string>
<string name="report_hateful_speech">Reportar lenguaje que incita al odio</string>
@ -220,6 +228,7 @@
<string name="biometric_authentication_failed">Error de autenticación</string>
<string name="biometric_authentication_failed_explainer">El sistema biométrico no pudo autenticar al propietario de este teléfono</string>
<string name="biometric_authentication_failed_explainer_with_error">El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s</string>
<string name="biometric_error">Error</string>
<string name="badge_created_by">"Creado por %1$s"</string>
<string name="badge_award_image_for">"Imagen de premio de insignia por %1$s"</string>
<string name="new_badge_award_notif">Recibiste un nuevo premio de insignia</string>
@ -259,6 +268,7 @@
<string name="report_dialog_impersonation">Suplantación de identidad malintencionada</string>
<string name="report_dialog_nudity">Desnudez o contenido gráfico</string>
<string name="report_dialog_illegal">Comportamiento ilegal</string>
<string name="report_dialog_malware">Malware</string>
<string name="report_dialog_blocking_a_user">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\".</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquear y ocultar usuario]]></string>
<string name="report_dialog_report_btn">Reportar abuso</string>
@ -296,6 +306,7 @@
<string name="poll_zap_value_min">Zap mínimo</string>
<string name="poll_zap_value_max">Zap máximo</string>
<string name="poll_consensus_threshold">Consenso</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Cerrar después de</string>
<string name="poll_closing_time_days">días</string>
<string name="poll_unable_to_vote">No se puede votar</string>
@ -326,9 +337,18 @@
<string name="zap_type_private_explainer">El remitente y el destinatario pueden verse entre sí y leer el mensaje</string>
<string name="zap_type_anonymous">Anónimo</string>
<string name="zap_type_anonymous_explainer">El receptor y el público no saben quién envió el pago</string>
<string name="zap_type_nonzap">No zap</string>
<string name="zap_type_nonzap_explainer">No hay rastro en Nostr, solo en Lightning</string>
<string name="file_server">Servidor de archivos</string>
<string name="zap_forward_lnAddress">Dirección o @usuario de Lightning</string>
<string name="media_servers">Servidores multimedia</string>
<string name="set_preferred_media_servers">Configura tus servidores preferidos para subir contenido multimedia.</string>
<string name="no_media_server_message">No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓</string>
<string name="built_in_media_servers_title">Servidores multimedia integrados</string>
<string name="built_in_servers_description">Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista.</string>
<string name="use_default_servers">Usar lista predeterminada</string>
<string name="add_media_server">Agregar servidor multimedia</string>
<string name="delete_media_server">Eliminar servidor multimedia</string>
<string name="upload_server_relays_nip95">Tus relés (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles.</string>
<string name="connect_via_tor_short">Configuración de Tor/Orbot</string>
@ -336,8 +356,10 @@
<string name="do_you_really_want_to_disable_tor_title">¿Desconectarse de tu Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Los datos se transferirán de inmediato en la red normal</string>
<string name="yes"></string>
<string name="no">No</string>
<string name="follow_list_selection">Lista de seguidos</string>
<string name="follow_list_kind3follows">Todos los seguidos</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="app_notification_dms_channel_description">Te notifica cuando llega un mensaje privado</string>
<string name="app_notification_zaps_channel_name">Zaps recibidos</string>
<string name="app_notification_zaps_channel_description">Te notifica cuando alguien te zapea</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">De %1$s</string>
<string name="app_notification_zaps_channel_message_for">por %1$s</string>
<string name="reply_notify">Notificar: </string>
@ -374,6 +397,7 @@
<string name="warn_when_posts_have_reports_from_your_follows">Avisar cuando las publicaciones tengan reportes de tus seguidos</string>
<string name="new_reaction_symbol">Nuevo símbolo de reacción</string>
<string name="no_reaction_type_setup_long_press_to_change">No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos.</string>
<string name="zapraiser">Recaudación de zaps</string>
<string name="zapraiser_explainer">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.</string>
<string name="zapraiser_target_amount_in_sats">Cantidad objetivo en sats</string>
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
@ -384,6 +408,7 @@
<string name="an_error_occurred_trying_to_get_relay_information">Se produjo un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
<string name="software">Software</string>
<string name="contact">Contacto</string>
<string name="supports">NIP compatibles</string>
<string name="admission_fees">Tarifas de admisión</string>
@ -402,6 +427,7 @@
<string name="maximum_event_tags">Etiquetas de evento máximas</string>
<string name="content_length">Longitud del contenido</string>
<string name="minimum_pow">PoW mínima</string>
<string name="auth">Autenticación</string>
<string name="payment">Pago</string>
<string name="cashu">Token de Cashu</string>
<string name="cashu_redeem">Canjear</string>
@ -423,6 +449,7 @@
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Publicaciones aprobadas</string>
<string name="groups_no_descriptor">Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una.</string>
<string name="community_no_descriptor">Esta comunidad no tiene descripción. Habla con el propietario para agregar una.</string>
@ -431,6 +458,7 @@
<string name="settings">Configuración</string>
<string name="connectivity_type_always">Siempre</string>
<string name="connectivity_type_wifi_only">Solo Wi-Fi</string>
<string name="connectivity_type_unmetered_wifi_only">WiFi sin medición</string>
<string name="connectivity_type_never">Nunca</string>
<string name="ui_feature_set_type_complete">Completo</string>
<string name="ui_feature_set_type_simplified">Simplificado</string>
@ -449,6 +477,7 @@
<string name="ui_style">Modo de interfaz</string>
<string name="ui_style_description">Elegir el estilo de publicación</string>
<string name="load_image">Cargar imagen</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Silenciado. Hacer clic para reactivar el sonido.</string>
<string name="mute_button">Sonido activado. Hacer clic para silenciar.</string>
<string name="search_button">Buscar grabaciones locales y remotas</string>
@ -496,6 +525,7 @@
<string name="poll_zap_value_min_max_explainer">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.</string>
<string name="error_dialog_zap_error">No se pudo enviar el zap</string>
<string name="error_dialog_talk_to_user">Mensaje para el usuario</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">No se pudo llegar a %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_assemble_url">Error al ensamblar la url NIP-11 para %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_reach_server">No se pudo contactar %1$s: %2$s</string>
@ -507,6 +537,8 @@
<string name="active_for">Activo para: </string>
<string name="active_for_home">Inicio</string>
<string name="active_for_msg">Mensajes directos</string>
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string>
<string name="active_for_search">Búsqueda</string>
<string name="zap_split_title">Dividir y reenviar zaps</string>
<string name="zap_split_explainer">Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti.</string>
@ -515,6 +547,7 @@
<string name="missing_lud16">Lightning no está configurado</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats.</string>
<string name="zap_split_weight">Porcentaje</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Dividir zaps con</string>
<string name="forwarding_zaps_to">Reenviar zaps a</string>
<string name="lightning_wallets_not_found2">No se encontraron billeteras de Lightning</string>
@ -581,9 +614,11 @@
<string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string>
<string name="classifieds">Vender un artículo</string>
<string name="classifieds_title">Título</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Estado</string>
<string name="classifieds_category">Categoría</string>
<string name="classifieds_price">Precio (en sats)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Ubicación</string>
<string name="classifieds_location_placeholder">Ciudad, estado, país.</string>
<string name="classifieds_condition_new">Nuevo</string>
@ -617,6 +652,8 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la subida</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido subido desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para subir: %1$s</string>
<string name="failed_to_upload_with_message">Error al subir: %1$s</string>
<string name="failed_to_delete_with_message">Error al eliminar: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@ -625,6 +662,7 @@
<string name="route_discover">Descubrir</string>
<string name="route_messages">Mensajes</string>
<string name="route_notifications">Notificaciones</string>
<string name="route_global">Global</string>
<string name="route_video">Cortos</string>
<string name="route_security_filters">Filtros de seguridad</string>
<string name="new_post">Nueva publicación</string>
@ -635,6 +673,8 @@
<string name="reply_description">Responder</string>
<string name="boost_or_quote_description">Impulsar o citar</string>
<string name="like_description">Me gusta</string>
<string name="zap_description">Zap</string>
<string name="change_reaction">Cambiar reacciones rápidas</string>
<string name="profile_image_of_user">Imagen de perfil de %1$s</string>
<string name="relay_info">Relé %1$s</string>
<string name="expand_relay_list">Ampliar lista de relés</string>
@ -645,6 +685,7 @@
<string name="add_bitcoin_invoice">Factura de Bitcoin</string>
<string name="cancel_bitcoin_invoice">Cancelar factura de Bitcoin</string>
<string name="cancel_classifieds">Cancelar la venta de un artículo</string>
<string name="add_zapraiser">Recaudación de zaps</string>
<string name="cancel_zapraiser">Cancelar zapraiser</string>
<string name="add_location">Ubicación</string>
<string name="remove_location">Eliminar ubicación</string>
@ -695,7 +736,9 @@
<string name="forked_from">Bifurcación de</string>
<string name="forked_tag">BIFURCACIÓN</string>
<string name="git_repository">Repositorio Git: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Clon:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Prueba de marca de tiempo</string>
<string name="ots_info_description">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.</string>
<string name="edit_post">Editar publicación</string>
@ -723,4 +766,34 @@
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu billetera</string>
<string name="dvm_waiting_to_confirm_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
<string name="http_status_400">Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud.</string>
<string name="http_status_401">No autorizado: el usuario no tiene credenciales de autenticación válidas.</string>
<string name="http_status_402">Pago requerido: el servidor requiere pago para completar la solicitud.</string>
<string name="http_status_403">Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud.</string>
<string name="http_status_404">No se encontró: el servidor no puede encontrar la dirección solicitada.</string>
<string name="http_status_405">Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino.</string>
<string name="http_status_406">No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud.</string>
<string name="http_status_407">Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas.</string>
<string name="http_status_408">Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor.</string>
<string name="http_status_409">Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso.</string>
<string name="http_status_410">Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá.</string>
<string name="http_status_411">Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida.</string>
<string name="http_status_412">Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple.</string>
<string name="http_status_413">Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla.</string>
<string name="http_status_414">URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. </string>
<string name="http_status_415">Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite.</string>
<string name="http_status_416">Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. </string>
<string name="http_status_417">Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect.</string>
<string name="http_status_426">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.</string>
<string name="http_status_500">Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud.</string>
<string name="http_status_501">No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud.</string>
<string name="http_status_502">Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host.</string>
<string name="http_status_503">Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento.</string>
<string name="http_status_504">Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta.</string>
<string name="http_status_505">Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud.</string>
<string name="http_status_506">La variante también negocia: el servidor tiene un error de configuración interno.</string>
<string name="http_status_507">Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud.</string>
<string name="http_status_508">Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud.</string>
<string name="http_status_511">Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red.</string>
<string name="add_a_nip96_server">Agregar servidor NIP-96</string>
</resources>

View File

@ -14,6 +14,7 @@
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
<string name="group_picture">Imagen del grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
@ -35,6 +36,7 @@
<string name="report_impersonation">Reportar suplantación de identidad</string>
<string name="report_explicit_content">Reportar contenido explícito</string>
<string name="report_illegal_behaviour">Reportar comportamiento ilegal</string>
<string name="report_malware">Reportar malware</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones</string>
<string name="login_with_a_private_key_to_like_posts">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.</string>
@ -44,11 +46,13 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">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.</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">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.</string>
<string name="login_with_a_private_key_to_be_able_to_show_word">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.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Total vistas</string>
<string name="boost">Impulsar</string>
<string name="boosted">Impulsada</string>
<string name="edited">editada</string>
<string name="edited_number">edición #%1$s</string>
<string name="original">original</string>
<string name="quote">Cita</string>
<string name="fork">Bifurcar</string>
<string name="propose_an_edit">Proponer una edición</string>
@ -89,6 +93,7 @@
<string name="failed_to_upload_the_image">Error al subir la imagen</string>
<string name="relay_address">Dirección del relé</string>
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
@ -195,6 +200,9 @@
<string name="nip_05">Dirección de Nostr</string>
<string name="never">nunca</string>
<string name="now">ahora</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Desnudez</string>
<string name="profanity_hateful_speech">Groserías o lenguaje que incita al odio</string>
<string name="report_hateful_speech">Reportar lenguaje que incita al odio</string>
@ -220,6 +228,7 @@
<string name="biometric_authentication_failed">Error de autenticación</string>
<string name="biometric_authentication_failed_explainer">El sistema biométrico no pudo autenticar al propietario de este teléfono</string>
<string name="biometric_authentication_failed_explainer_with_error">El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s</string>
<string name="biometric_error">Error</string>
<string name="badge_created_by">"Creado por %1$s"</string>
<string name="badge_award_image_for">"Imagen de premio de insignia por %1$s"</string>
<string name="new_badge_award_notif">Recibiste un nuevo premio de insignia</string>
@ -259,6 +268,7 @@
<string name="report_dialog_impersonation">Suplantación de identidad malintencionada</string>
<string name="report_dialog_nudity">Desnudez o contenido gráfico</string>
<string name="report_dialog_illegal">Comportamiento ilegal</string>
<string name="report_dialog_malware">Malware</string>
<string name="report_dialog_blocking_a_user">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\".</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquear y ocultar usuario]]></string>
<string name="report_dialog_report_btn">Reportar abuso</string>
@ -296,6 +306,7 @@
<string name="poll_zap_value_min">Zap mínimo</string>
<string name="poll_zap_value_max">Zap máximo</string>
<string name="poll_consensus_threshold">Consenso</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Cerrar después de</string>
<string name="poll_closing_time_days">días</string>
<string name="poll_unable_to_vote">No se puede votar</string>
@ -326,9 +337,18 @@
<string name="zap_type_private_explainer">El remitente y el receptor pueden verse entre sí y leer el mensaje</string>
<string name="zap_type_anonymous">Anónimo</string>
<string name="zap_type_anonymous_explainer">El receptor y el público no saben quién envió el pago</string>
<string name="zap_type_nonzap">No zap</string>
<string name="zap_type_nonzap_explainer">No hay rastro en Nostr, solo en Lightning</string>
<string name="file_server">Servidor de archivos</string>
<string name="zap_forward_lnAddress">Dirección o @usuario de Lightning</string>
<string name="media_servers">Servidores multimedia</string>
<string name="set_preferred_media_servers">Configura tus servidores preferidos para subir contenido multimedia.</string>
<string name="no_media_server_message">No tienes ningún servidor multimedia personalizado. Puedes utilizar la lista de Amethyst, o agregar uno de la lista a continuación ↓</string>
<string name="built_in_media_servers_title">Servidores multimedia integrados</string>
<string name="built_in_servers_description">Lista predeterminada de Amethyst. Puedes agregarlos individualmente o añadir la lista.</string>
<string name="use_default_servers">Usar lista predeterminada</string>
<string name="add_media_server">Agregar servidor multimedia</string>
<string name="delete_media_server">Eliminar servidor multimedia</string>
<string name="upload_server_relays_nip95">Tus relés (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles.</string>
<string name="connect_via_tor_short">Configuración de Tor/Orbot</string>
@ -336,8 +356,10 @@
<string name="do_you_really_want_to_disable_tor_title">¿Desconectarse de tu Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Los datos se transferirán de inmediato en la red normal</string>
<string name="yes"></string>
<string name="no">No</string>
<string name="follow_list_selection">Lista de seguidos</string>
<string name="follow_list_kind3follows">Todos los seguidos</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="app_notification_dms_channel_description">Te notifica cuando llega un mensaje privado</string>
<string name="app_notification_zaps_channel_name">Zaps recibidos</string>
<string name="app_notification_zaps_channel_description">Te notifica cuando alguien te zapea</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">De %1$s</string>
<string name="app_notification_zaps_channel_message_for">por %1$s</string>
<string name="reply_notify">Notificar: </string>
@ -374,6 +397,7 @@
<string name="warn_when_posts_have_reports_from_your_follows">Avisar cuando las publicaciones tengan reportes de tus seguidos</string>
<string name="new_reaction_symbol">Nuevo símbolo de reacción</string>
<string name="no_reaction_type_setup_long_press_to_change">No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos.</string>
<string name="zapraiser">Recaudación de zaps</string>
<string name="zapraiser_explainer">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.</string>
<string name="zapraiser_target_amount_in_sats">Cantidad objetivo en sats</string>
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
@ -384,6 +408,7 @@
<string name="an_error_occurred_trying_to_get_relay_information">Se produjo un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
<string name="software">Software</string>
<string name="contact">Contacto</string>
<string name="supports">NIP compatibles</string>
<string name="admission_fees">Comisiones de admisión</string>
@ -402,6 +427,7 @@
<string name="maximum_event_tags">Etiquetas de evento máximas</string>
<string name="content_length">Longitud del contenido</string>
<string name="minimum_pow">PoW mínima</string>
<string name="auth">Autenticación</string>
<string name="payment">Pago</string>
<string name="cashu">Token de Cashu</string>
<string name="cashu_redeem">Canjear</string>
@ -423,6 +449,7 @@
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Publicaciones aprobadas</string>
<string name="groups_no_descriptor">Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una.</string>
<string name="community_no_descriptor">Esta comunidad no tiene descripción. Habla con el propietario para agregar una.</string>
@ -431,6 +458,7 @@
<string name="settings">Configuración</string>
<string name="connectivity_type_always">Siempre</string>
<string name="connectivity_type_wifi_only">Solo Wi-Fi</string>
<string name="connectivity_type_unmetered_wifi_only">WiFi sin medición</string>
<string name="connectivity_type_never">Nunca</string>
<string name="ui_feature_set_type_complete">Completo</string>
<string name="ui_feature_set_type_simplified">Simplificado</string>
@ -449,6 +477,7 @@
<string name="ui_style">Modo de interfaz</string>
<string name="ui_style_description">Elegir el estilo de publicación</string>
<string name="load_image">Cargar imagen</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Silenciado. Hacer clic para reactivar el sonido.</string>
<string name="mute_button">Sonido activado. Hacer clic para silenciar.</string>
<string name="search_button">Buscar grabaciones locales y remotas</string>
@ -496,6 +525,7 @@
<string name="poll_zap_value_min_max_explainer">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.</string>
<string name="error_dialog_zap_error">No se pudo enviar el zap</string>
<string name="error_dialog_talk_to_user">Mensaje para el usuario</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">No se pudo llegar a %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_assemble_url">Error al ensamblar la url NIP-11 para %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_reach_server">No se pudo contactar %1$s: %2$s</string>
@ -507,6 +537,8 @@
<string name="active_for">Activo para: </string>
<string name="active_for_home">Inicio</string>
<string name="active_for_msg">Mensajes directos</string>
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string>
<string name="active_for_search">Búsqueda</string>
<string name="zap_split_title">Dividir y reenviar zaps</string>
<string name="zap_split_explainer">Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de a ti.</string>
@ -515,6 +547,7 @@
<string name="missing_lud16">Lightning no está configurado</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats.</string>
<string name="zap_split_weight">Porcentaje</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Dividir zaps con</string>
<string name="forwarding_zaps_to">Reenviar zaps a</string>
<string name="lightning_wallets_not_found2">No se encontraron billeteras de Lightning</string>
@ -581,9 +614,11 @@
<string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string>
<string name="classifieds">Vender un artículo</string>
<string name="classifieds_title">Título</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Estado</string>
<string name="classifieds_category">Categoría</string>
<string name="classifieds_price">Precio (en sats)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Ubicación</string>
<string name="classifieds_location_placeholder">Ciudad, estado, país.</string>
<string name="classifieds_condition_new">Nuevo</string>
@ -617,6 +652,8 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la subida</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido subido desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para subir: %1$s</string>
<string name="failed_to_upload_with_message">Error al subir: %1$s</string>
<string name="failed_to_delete_with_message">Error al eliminar: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@ -625,6 +662,7 @@
<string name="route_discover">Descubrir</string>
<string name="route_messages">Mensajes</string>
<string name="route_notifications">Notificaciones</string>
<string name="route_global">Global</string>
<string name="route_video">Cortos</string>
<string name="route_security_filters">Filtros de seguridad</string>
<string name="new_post">Nueva publicación</string>
@ -635,6 +673,8 @@
<string name="reply_description">Responder</string>
<string name="boost_or_quote_description">Impulsar o citar</string>
<string name="like_description">Me gusta</string>
<string name="zap_description">Zap</string>
<string name="change_reaction">Cambiar reacciones rápidas</string>
<string name="profile_image_of_user">Imagen de perfil de %1$s</string>
<string name="relay_info">Relé %1$s</string>
<string name="expand_relay_list">Ampliar lista de relés</string>
@ -645,6 +685,7 @@
<string name="add_bitcoin_invoice">Factura de Bitcoin</string>
<string name="cancel_bitcoin_invoice">Cancelar factura de Bitcoin</string>
<string name="cancel_classifieds">Cancelar la venta de un artículo</string>
<string name="add_zapraiser">Recaudación de zaps</string>
<string name="cancel_zapraiser">Cancelar zapraiser</string>
<string name="add_location">Ubicación</string>
<string name="remove_location">Eliminar ubicación</string>
@ -695,7 +736,9 @@
<string name="forked_from">Bifurcación de</string>
<string name="forked_tag">BIFURCACIÓN</string>
<string name="git_repository">Repositorio Git: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Clon:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Prueba de marca de tiempo</string>
<string name="ots_info_description">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.</string>
<string name="edit_post">Editar publicación</string>
@ -723,4 +766,34 @@
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu billetera</string>
<string name="dvm_waiting_to_confirm_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
<string name="http_status_400">Solicitud incorrecta: el servidor no puede procesar o no procesará la solicitud.</string>
<string name="http_status_401">No autorizado: el usuario no tiene credenciales de autenticación válidas.</string>
<string name="http_status_402">Pago requerido: el servidor requiere pago para completar la solicitud.</string>
<string name="http_status_403">Prohibido: el usuario no tiene derechos de acceso para realizar la solicitud.</string>
<string name="http_status_404">No se encontró: el servidor no puede encontrar la dirección solicitada.</string>
<string name="http_status_405">Método no permitido: el servidor admite el método de solicitud, pero no el recurso de destino.</string>
<string name="http_status_406">No aceptable: el servidor no encuentra ningún contenido que satisfaga la solicitud.</string>
<string name="http_status_407">Autenticación de proxy requerida: el usuario no tiene credenciales de autenticación válidas.</string>
<string name="http_status_408">Tiempo de espera de la solicitud: se agotó el tiempo de espera del servidor.</string>
<string name="http_status_409">Conflicto: el servidor no puede satisfacer la solicitud porque hay un conflicto con el recurso.</string>
<string name="http_status_410">Borrado: el contenido solicitado se eliminó permanentemente del servidor y no se restablecerá.</string>
<string name="http_status_411">Longitud requerida: el servidor rechaza la solicitud porque requiere una longitud definida.</string>
<string name="http_status_412">Precondición fallida: las precondiciones de la solicitud en los campos de cabecera que el servidor no cumple.</string>
<string name="http_status_413">Carga demasiado grande: la solicitud supera los límites definidos por el servidor, por lo que este se niega a procesarla.</string>
<string name="http_status_414">URI demasiado largo: la URL solicitada por el cliente es demasiado larga para que el servidor pueda procesarla. </string>
<string name="http_status_415">Tipo de contenido multimedia no admitido: la solicitud usa un formato multimedia que el servidor no admite.</string>
<string name="http_status_416">Intervalo no satisfactorio: el servidor no puede satisfacer el valor indicado en el campo de cabecera del intervalo de la solicitud. </string>
<string name="http_status_417">Expectativa fallida: el servidor no puede cumplir los requisitos indicados por el campo de cabecera de la solicitud Expect.</string>
<string name="http_status_426">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.</string>
<string name="http_status_500">Error interno del servidor: el servidor encontró un error inesperado y no puede completar la solicitud.</string>
<string name="http_status_501">No implementado: el servidor no puede completar la solicitud o no reconoce el método de solicitud.</string>
<string name="http_status_502">Gateway incorrecto: el servidor actúa como gateway y obtuvo una respuesta no válida del host.</string>
<string name="http_status_503">Servicio no disponible: esto ocurre a menudo cuando un servidor está sobrecargado o caído por mantenimiento.</string>
<string name="http_status_504">Tiempo de espera del gateway: el servidor estaba actuando como gateway o proxy y se agotó el tiempo de espera de una respuesta.</string>
<string name="http_status_505">Versión HTTP no admitida: el servidor no admite la versión HTTP de la solicitud.</string>
<string name="http_status_506">La variante también negocia: el servidor tiene un error de configuración interno.</string>
<string name="http_status_507">Almacenamiento insuficiente: el servidor no dispone de almacenamiento suficiente para procesar correctamente la solicitud.</string>
<string name="http_status_508">Bucle detectado: el servidor detecta un bucle infinito al procesar la solicitud.</string>
<string name="http_status_511">Autenticación de red requerida: el cliente debe estar autenticado para acceder a la red.</string>
<string name="add_a_nip96_server">Agregar servidor NIP-96</string>
</resources>

View File

@ -36,6 +36,7 @@
<string name="report_impersonation">Signaler une falsification d\'identité</string>
<string name="report_explicit_content">Signaler un contenu choquant</string>
<string name="report_illegal_behaviour">Signaler un comportement illégal</string>
<string name="report_malware">Signaler un programme malveillant</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Connectez vous avec une clé privée pour pouvoir répondre</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Connectez vous avec une clé privée pour pouvoir booster des posts</string>
<string name="login_with_a_private_key_to_like_posts">Connectez vous avec une clé privée pour pouvoir aimer des posts</string>
@ -267,6 +268,7 @@
<string name="report_dialog_impersonation">Usurpation d\'identité</string>
<string name="report_dialog_nudity">Nudité ou contenu graphique</string>
<string name="report_dialog_illegal">Comportement illégal</string>
<string name="report_dialog_malware">Programme malveillant</string>
<string name="report_dialog_blocking_a_user">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é.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquer et Masquer l\'Utilisateur]]></string>
<string name="report_dialog_report_btn">Signaler un Abus</string>
@ -339,6 +341,14 @@
<string name="zap_type_nonzap_explainer">Aucune trace sur Nostr, seulement sur le Lightning</string>
<string name="file_server">Serveur fichier</string>
<string name="zap_forward_lnAddress">Adresse LN ou @Utilisateur</string>
<string name="media_servers">Serveurs média</string>
<string name="set_preferred_media_servers">Définissez vos serveurs de téléversement de média préférés.</string>
<string name="no_media_server_message">Vous n\'avez pas de serveur média personnalisé. Vous pouvez utiliser la liste d\'Amethyst, ou en ajouter un ci-dessous</string>
<string name="built_in_media_servers_title">Serveurs média intégrés</string>
<string name="built_in_servers_description">Liste par défaut d\'Amethyst. Vous pouvez les ajouter individuellement ou ajouter la liste.</string>
<string name="use_default_servers">Utiliser la liste par défaut</string>
<string name="add_media_server">Ajouter un serveur média</string>
<string name="delete_media_server">Supprimer le serveur média</string>
<string name="upload_server_relays_nip95">Vos relais (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Les fichiers sont hébergés par vos relais. Nouveau NIP: vérifiez s\'ils sont supportés</string>
<string name="connect_via_tor_short">Configuration Tor/Orbot</string>
@ -642,6 +652,8 @@
<string name="server_did_not_provide_a_url_after_uploading">Le serveur n\'a pas fourni d\'URL après le téléversement</string>
<string name="could_not_download_from_the_server">Impossible de télécharger le média depuis le serveur</string>
<string name="could_not_prepare_local_file_to_upload">Impossible de préparer le fichier local à téléverser: %1$s</string>
<string name="failed_to_upload_with_message">Échec du téléversement: %1$s</string>
<string name="failed_to_delete_with_message">Échec de la suppression : %1$s</string>
<string name="edit_draft">Modifier le brouillon</string>
<string name="login_with_qr_code">Se connecter avec un QR Code</string>
<string name="route">Route</string>
@ -754,4 +766,5 @@
<string name="dvm_requesting_job">Tâche demandée par DVM</string>
<string name="nwc_payment_request">Demande de paiement envoyée, en attente de confirmation depuis votre portefeuille</string>
<string name="dvm_waiting_to_confirm_payment">En attente que DVM confirme le paiement ou envoie les résultats</string>
<string name="add_a_nip96_server">Ajouter un serveur NIP-96</string>
</resources>

View File

@ -36,6 +36,7 @@
<string name="report_impersonation">पररूपण की सूचना दें</string>
<string name="report_explicit_content">अभद्र विषयवस्तु की सूचना दें</string>
<string name="report_illegal_behaviour">अवैध बरताव की सूचना दें</string>
<string name="report_malware">दुष्क्रमक की सूचना दें</string>
<string name="login_with_a_private_key_to_be_able_to_reply">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। उत्तर देने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को उद्धृत करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
<string name="login_with_a_private_key_to_like_posts">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को चाहने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
@ -267,6 +268,7 @@
<string name="report_dialog_impersonation">दुर्भावनापूर्ण पररूपण</string>
<string name="report_dialog_nudity">नग्नता अथवा आपत्तिजनक विषयवस्तु</string>
<string name="report_dialog_illegal">अवैध बरताव</string>
<string name="report_dialog_malware">दुष्क्रमक</string>
<string name="report_dialog_blocking_a_user">उपयोगकर्ता को बाधित करने से उनका विषयवस्तु आपके लिए छिपाया जाएगा क्रमक में। आपके टीकाएँ सार्वजनिक रूप से दृश्य रहेंगे, उन जनों के लिए भी जिनहे आपने बाधित किया। बाधित उपयोगकर्ता सूचित हैं सुरक्षा छलनियाँ पटल पर।</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[उपयोगकर्ता को बाधित करें तथा छिपाएँ]]></string>
<string name="report_dialog_report_btn">दुर्व्यवहार की सूचना दें</string>
@ -339,6 +341,14 @@
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
<string name="file_server">अभिलेख सेवासंगणक</string>
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
<string name="media_servers">प्रसारसंगणक</string>
<string name="set_preferred_media_servers">आपके प्रसारसंगणक आद्यताएँ स्थापित करें।</string>
<string name="no_media_server_message">आपका कोई विशिष्ट प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा प्रसारसंगणक नीचे जोड सकते हैं ↓</string>
<string name="built_in_media_servers_title">अन्तर्निहित प्रसारसंगणक</string>
<string name="built_in_servers_description">अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं।</string>
<string name="use_default_servers">मूलविकल्प सूची का प्रयोग करें</string>
<string name="add_media_server">प्रसारसंगणक जोडें</string>
<string name="delete_media_server">प्रसारसंगणक मिटाएँ</string>
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (निप॰-९५)</string>
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं</string>
<string name="connect_via_tor_short">टोर / ओर्बोट स्थापन</string>
@ -785,4 +795,5 @@
<string name="http_status_507">स्मृतिस्थान का अभाव - सेवासंगणक में पर्याप्त स्मृतिस्थान उपलब्ध नहीं अनुरोध पर सफलतापूर्वक काम करने के लिए</string>
<string name="http_status_508">क्रमचक्र दृष्ट - सेवासंगणक को अनन्त क्रमचक्र का पता चला अनुरोध पर काम करते हुए</string>
<string name="http_status_511">जाल प्रमाणीकरण आवश्यक - जाल उपलब्ध होने के लिए ग्राहक का प्रमाणीकरण अनिवार्य</string>
<string name="add_a_nip96_server">निप॰-९६ सेवासंगणक जोडें</string>
</resources>

View File

@ -36,6 +36,7 @@
<string name="report_impersonation">Zgłoś podszywanie się</string>
<string name="report_explicit_content">Zgłoś niedozwoloną zawartość</string>
<string name="report_illegal_behaviour">Zgłoś antyspołeczne działania</string>
<string name="report_malware">Zgłoś złośliwe oprogramowanie</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc odpowiedzieć</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów</string>
<string name="login_with_a_private_key_to_like_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty</string>
@ -267,6 +268,7 @@
<string name="report_dialog_impersonation">Złośliwe podszywanie się</string>
<string name="report_dialog_nudity">Nagość lub obraźliwa grafika</string>
<string name="report_dialog_illegal">Antyspołeczne zachowanie</string>
<string name="report_dialog_malware">Złośliwe oprogramowanie</string>
<string name="report_dialog_blocking_a_user">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.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Zablokuj i ukryj użytkownika]]></string>
<string name="report_dialog_report_btn">Zgłoś nadużycie</string>
@ -339,6 +341,14 @@
<string name="zap_type_nonzap_explainer">Brak śladu w Nostr, tylko w Lightning</string>
<string name="file_server">Serwer Plików</string>
<string name="zap_forward_lnAddress">LnAdres lub @Użytkownik</string>
<string name="media_servers">Serwery Multimediów</string>
<string name="set_preferred_media_servers">Ustaw swoje preferowane serwery przesyłania multimediów.</string>
<string name="no_media_server_message">Nie masz ustawionych własnych serwerów multimediów. Możesz użyć listy Amethyst\'a lub dodać jeden poniżej ↓</string>
<string name="built_in_media_servers_title">Wbudowane serwery multimediów</string>
<string name="built_in_servers_description">Domyślna lista Amethyst-a. Możesz dodawać je pojedynczo lub dodać pełną listę.</string>
<string name="use_default_servers">Użyj domyślnej listy</string>
<string name="add_media_server">Dodaj serwer multimediów</string>
<string name="delete_media_server">Usuń serwer multimediów</string>
<string name="upload_server_relays_nip95">Twoje transmitery (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany</string>
<string name="connect_via_tor_short">Tor/Orbot - Ustawienia</string>
@ -426,6 +436,8 @@
<string name="cashu_copy_token">Skopiuj token</string>
<string name="no_lightning_address_set">Nie ustawiono adresu Lightning</string>
<string name="copied_token_to_clipboard">Skopiowano token do schowka</string>
<string name="live_stream_live_tag">NA ŻYWO</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">Zakończony</string>
<string name="live_stream_planned_tag">ZAPLANOWANE</string>
<string name="live_stream_is_offline">Transmisja wyłączona</string>
@ -776,4 +788,5 @@
<string name="http_status_507">Niewystarczająca pojemność - serwer nie ma wystarczającej pojemność, aby pomyślnie przetworzyć żądanie</string>
<string name="http_status_508">Wykryta pętla - serwer wykrywa nieskończoną pętlę podczas przetwarzania żądania</string>
<string name="http_status_511">Wymagane uwierzytelnienie sieciowe - Klient musi być uwierzytelniony aby uzyskać dostęp do sieci</string>
<string name="add_a_nip96_server">Dodaj serwer NIP-96</string>
</resources>

View File

@ -10,6 +10,7 @@
<string name="post_was_hidden">This post was hidden because it mentions your hidden users or words</string>
<string name="post_was_flagged_as_inappropriate_by">Post was muted or reported by</string>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">Referenced event not found</string>
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
@ -135,6 +136,7 @@
<string name="conversations">Conversations</string>
<string name="notes">Notes</string>
<string name="replies">Replies</string>
<string name="gallery">Gallery</string>
<string name="follows">"Follows"</string>
<string name="reports">"Reports"</string>
<string name="more_options">More Options</string>
@ -271,6 +273,8 @@
<string name="quick_action_delete">Delete</string>
<string name="quick_action_unfollow">Unfollow</string>
<string name="quick_action_follow">Follow</string>
<string name="quick_action_request_deletion_gallery_title">Delete from Gallery</string>
<string name="quick_action_request_deletion_gallery_alert_body">Remove this media from your Gallery, you can readd it later</string>
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
<string name="quick_action_request_deletion_alert_body">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.</string>
<string name="quick_action_block_dialog_btn">Block</string>
@ -598,6 +602,7 @@
<string name="share_or_save">Share or Save</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
<string name="created_at">Created at</string>
<string name="rules">Rules</string>

View File

@ -75,7 +75,7 @@ class Relay(
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
private val authResponse = mutableMapOf<HexKey, Boolean>()
private val sendWhenReady = mutableListOf<EventInterface>()
private val outboxCache = mutableMapOf<HexKey, EventInterface>()
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()
}
}
}

View File

@ -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 {

View File

@ -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')

View File

@ -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()
}

View File

@ -24,4 +24,4 @@ android.nonTransitiveRClass=true
android.enableR8.fullMode=true
android.nonFinalResIds=false
kotlin.daemon.jvmargs=-Xmx4096m
kotlin.daemon.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=3g

View File

@ -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" }

View File

@ -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 ->

View File

@ -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<Array<String>>,
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<Array<String>>,
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<Array<String>>,
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
}
}
}
}
}

View File

@ -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<Array<String>>,
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<String>) = 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)
}
}
}