Merge conflict-resolved changes from upstream

This commit is contained in:
KotlinGeekDev
2025-11-21 19:09:50 +01:00
91 changed files with 8778 additions and 944 deletions
+1
View File
@@ -18,6 +18,7 @@
/.idea/artifacts/*
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
.DS_Store
/build
/captures
+6673
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -44,9 +44,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 428
versionName = generateVersionName("1.03.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
versionCode = 430
versionName = generateVersionName("1.04.2")
buildConfigField "String", "RELEASE_NOTES_ID", "\"3a03c75d85aaf6b181d3b232d064c4d4feea5c73f0bea2bd91ed61b8da7cd6a6\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -151,7 +151,7 @@ android {
signingConfig = signingConfigs.debug
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
@@ -110,7 +110,7 @@ class ImageUploadTesting {
.upload(
inputStream = inputStream,
hash = initialHash,
length = paylod.size,
length = paylod.size.toLong(),
baseFileName = "filename.png",
contentType = "image/png",
alt = null,
+36
View File
@@ -133,6 +133,42 @@
<data android:host="njump.me" />
</intent-filter>
<intent-filter android:label="njump.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="njump.to" />
</intent-filter>
<intent-filter android:label="iris.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="iris.to" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="zap.stream" />
<data android:pathPrefix="/p/" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:label="shosho.live">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="shosho.live" />
<data android:pathPrefix="/profile/" />
<data android:pathPrefix="/live/" />
</intent-filter>
<intent-filter android:label="Primal">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -58,17 +58,6 @@ fun debugState(context: Context) {
Log.d(STATE_DUMP_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)")
}
Log.d(
STATE_DUMP_TAG,
"Connected Relays: " +
Amethyst.instance.client
.relayStatusFlow()
.value.connected.size + "/" +
Amethyst.instance.client
.relayStatusFlow()
.value.available.size,
)
Log.d(
STATE_DUMP_TAG,
"Indexed Relays: " +
@@ -77,9 +77,9 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Serializable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean,
val hasPrivKey: Boolean = false,
val loggedInWithExternalSigner: Boolean = false,
val isTransient: Boolean = false,
)
private object PrefKeys {
@@ -706,7 +706,7 @@ class Account(
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> {
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
// everywhere
return followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available
return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value
}
if (event is GiftWrapEvent) {
val receiver = event.recipientPubKey()
@@ -906,7 +906,7 @@ class Account(
}
fun sendLiterallyEverywhere(event: Event) {
client.send(event, followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available)
client.send(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
cache.justConsumeMyOwnEvent(event)
}
@@ -89,9 +89,9 @@ class HashtagListState(
val hashtagList = getHashtagList()
return if (hashtagList == null) {
HashtagListEvent.create(hashtag, true, signer)
HashtagListEvent.create(hashtag.lowercase(), true, signer)
} else {
HashtagListEvent.add(hashtagList, hashtag, true, signer)
HashtagListEvent.add(hashtagList, hashtag.lowercase(), true, signer)
}
}
@@ -64,7 +64,7 @@ fun GetVideoController(
if (controllerId.needsController()) {
// If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
@@ -172,7 +172,7 @@ fun GetVideoController(
// if the controller is not null, just continue playing what the controller was playing
if (controllerId.needsController()) {
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.Main) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
@@ -154,7 +154,7 @@ class AccountFollowsLoaderSubAssembler(
println("AccountFollowNeeds ${users.size}")
val connectedRelays = client.relayStatusFlow().value.connected
val connectedRelays = client.connectedRelaysFlow().value
val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried)
@@ -96,7 +96,7 @@ class UserOutboxFinderSubAssembler(
if (noOutboxList.isEmpty()) return null
val accounts = keys.mapTo(mutableSetOf()) { it.account }
val connectedRelays = client.relayStatusFlow().value.connected
val connectedRelays = client.connectedRelaysFlow().value
val perRelayKeysBoth =
pickRelaysToLoadUsers(
@@ -141,7 +141,7 @@ fun uriToRoute(
return Route.Notification
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id="))
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
}
val nip19 = Nip19Parser.uriToRoute(uri)?.entity
@@ -50,12 +50,14 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
@@ -64,11 +66,12 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Configuration for zap button behavior and appearance
@@ -91,6 +94,7 @@ data class ZapButtonCallbacks(
val onZapComplete: ((Boolean) -> Unit)? = null,
)
@OptIn(ExperimentalUuidApi::class)
@Composable
fun ReusableZapButton(
baseNote: Note,
@@ -100,9 +104,6 @@ fun ReusableZapButton(
callbacks: ZapButtonCallbacks = ZapButtonCallbacks(),
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(persistentListOf())
}
// Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons)
if (config.showUserFinderSubscription) {
@@ -138,7 +139,19 @@ fun ReusableZapButton(
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(it, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
},
modifier = Modifier.fillMaxWidth(),
@@ -166,25 +179,17 @@ fun ReusableZapButton(
onProgress = {
scope.launch(Dispatchers.Main) { zappingProgress = it }
},
onPayViaIntent = { wantsToPay = it },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(it, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
@@ -692,7 +692,7 @@ fun HashTag(
modifier =
remember {
Modifier.clickable {
nav.nav(Route.Hashtag(segment.hashtag))
nav.nav(Route.Hashtag(segment.hashtag.lowercase()))
}
},
inlineContent =
@@ -67,14 +67,14 @@ class FilterByListParams(
fun match(
noteEvent: Event,
comingFrom: List<NormalizedRelayUrl> = emptyList(),
comingFrom: List<NormalizedRelayUrl>,
) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) &&
(isHiddenList || isNotHidden(noteEvent.pubKey)) &&
isNotInTheFuture(noteEvent)
fun match(
address: Address?,
comingFrom: List<NormalizedRelayUrl> = emptyList(),
comingFrom: List<NormalizedRelayUrl>,
) = address != null &&
(isGlobal(comingFrom) || isAuthorInFollows(address)) &&
(isHiddenList || isNotHidden(address.pubKeyHex))
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute
import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
@@ -91,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
@@ -142,6 +144,10 @@ fun AppNavigation(
composableFromEnd<Route.BookmarkGroups> { ListOfBookmarkGroupsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BookmarkGroupView> { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -106,18 +106,17 @@ import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font18SP
import com.vitorpamplona.amethyst.ui.theme.IconRowModifier
import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Width16Space
import com.vitorpamplona.amethyst.ui.theme.bannerModifier
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -125,7 +124,8 @@ import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@Composable
fun DrawerContent(
@@ -546,40 +546,6 @@ fun ListContent(
}
}
@Composable
private fun RelayStatus(accountViewModel: AccountViewModel) {
val connectedRelaysText by accountViewModel.account.client
.relayStatusFlow()
.collectAsStateWithLifecycle()
RenderRelayStatus(connectedRelaysText)
}
@Composable
private fun RenderRelayStatus(relayPool: RelayPool.RelayPoolStatus) {
val text by
remember(relayPool) { derivedStateOf { "${relayPool.connected.size}/${relayPool.available.size}" } }
val placeHolder = MaterialTheme.colorScheme.placeholderText
val color by
remember(relayPool) {
derivedStateOf {
if (relayPool.isConnected) {
placeHolder
} else {
Color.Red
}
}
}
Text(
text = text,
color = color,
style = MaterialTheme.typography.titleMedium,
)
}
@Composable
fun NavigationRow(
title: Int,
@@ -700,44 +666,61 @@ fun IconRowRelays(
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick() },
.padding(vertical = 15.dp, horizontal = 25.dp)
.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterRes(R.drawable.relays, 4),
contentDescription = stringRes(R.string.relay_setup),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Icon(
painter = painterRes(R.drawable.relays, 4),
contentDescription = stringRes(R.string.relay_setup),
modifier = Size22Modifier,
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = stringRes(id = R.string.relay_setup),
fontSize = 18.sp,
)
Text(
modifier = IconRowTextModifier,
text = stringRes(id = R.string.relay_setup),
fontSize = Font18SP,
)
Spacer(modifier = Modifier.width(Size16dp))
Spacer(modifier = Width16Space)
RelayStatus(accountViewModel = accountViewModel)
}
RelayStatus(accountViewModel)
}
}
class PoolStatus(
val share: String,
val isConnected: Boolean,
)
@Composable
private fun RelayStatus(accountViewModel: AccountViewModel) {
val statusCounterFlow: Flow<PoolStatus> =
remember(accountViewModel) {
combine(
accountViewModel.account.client.connectedRelaysFlow(),
accountViewModel.account.client.availableRelaysFlow(),
) { connected, available ->
PoolStatus("${connected.size}/${available.size}", connected.isNotEmpty())
}
}
val relayPool by statusCounterFlow.collectAsStateWithLifecycle(PoolStatus("", false))
Text(
text = relayPool.share,
color = if (relayPool.isConnected) MaterialTheme.colorScheme.placeholderText else Color.Red,
style = MaterialTheme.typography.titleMedium,
)
}
@Composable
fun BottomContent(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
// store the dialog open or close state
var dialogOpen by remember { mutableStateOf(false) }
Column(modifier = Modifier) {
HorizontalDivider(
modifier = Modifier.padding(top = 15.dp),
@@ -781,7 +764,7 @@ fun BottomContent(
Box(modifier = Modifier.weight(1F))
IconButton(
onClick = {
dialogOpen = true
nav.nav(Route.QRDisplay(user.pubkeyHex))
nav.closeDrawer()
},
) {
@@ -794,17 +777,4 @@ fun BottomContent(
}
}
}
if (dialogOpen) {
ShowQRDialog(
user,
accountViewModel,
onScan = {
dialogOpen = false
nav.closeDrawer()
nav.nav(it)
},
onClose = { dialogOpen = false },
)
}
}
@@ -97,6 +97,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class QRDisplay(
val pubkey: String,
) : Route()
@Serializable data class ContentDiscovery(
val id: String,
) : Route()
@@ -260,6 +264,11 @@ sealed class Route {
val version: String? = null,
val draft: String? = null,
) : Route()
@Serializable
data class ManualZapSplitPayment(
val paymentId: String,
) : Route()
}
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
@@ -290,6 +299,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.Geohash>() -> entry.toRoute<Route.Geohash>()
dest.hasRoute<Route.Community>() -> entry.toRoute<Route.Community>()
dest.hasRoute<Route.QRDisplay>() -> entry.toRoute<Route.QRDisplay>()
dest.hasRoute<Route.RelayInfo>() -> entry.toRoute<Route.RelayInfo>()
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
@@ -318,6 +328,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.PeopleListManagement>() -> entry.toRoute<Route.PeopleListManagement>()
dest.hasRoute<Route.NewGroupDM>() -> entry.toRoute<Route.NewGroupDM>()
dest.hasRoute<Route.UserSettings>() -> entry.toRoute<Route.UserSettings>()
dest.hasRoute<Route.ManualZapSplitPayment>() -> entry.toRoute<Route.ManualZapSplitPayment>()
else -> {
null
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
@@ -122,19 +121,17 @@ fun nip05VerificationAsAState(
@Composable
fun ObserveDisplayNip05Status(
baseNote: Note,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchAuthor(baseNote = baseNote, accountViewModel) {
ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav)
ObserveDisplayNip05Status(it, accountViewModel, nav)
}
}
@Composable
fun ObserveDisplayNip05Status(
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -143,14 +140,12 @@ fun ObserveDisplayNip05Status(
CrossfadeIfEnabled(
targetState = nip05,
modifier = columnModifier,
accountViewModel = accountViewModel,
) {
VerifyAndDisplayNIP05OrStatusLine(
it,
statuses,
baseUser,
columnModifier,
accountViewModel,
nav,
)
@@ -162,29 +157,26 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
nip05: String?,
statuses: ImmutableList<AddressableNote>,
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else if (!statuses.isEmpty()) {
ObserveRotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
}
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else if (!statuses.isEmpty()) {
ObserveRotateStatuses(statuses, accountViewModel, nav)
} else {
if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayUsersNpub(baseUser.pubkeyDisplayHex())
}
DisplayNIP05(nip05, nip05Verified, accountViewModel)
}
} else {
if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayUsersNpub(baseUser.pubkeyDisplayHex())
}
}
}
@@ -253,7 +245,7 @@ fun DisplayStatus(
nav: INav,
) {
val noteState by observeNote(addressableNote, accountViewModel)
val noteEvent = noteState?.note?.event as? StatusEvent ?: return
val noteEvent = noteState.note.event as? StatusEvent ?: return
DisplayStatus(noteEvent, accountViewModel, nav)
}
@@ -1073,10 +1073,12 @@ fun SecondUserInfoRow(
verticalAlignment = CenterVertically,
modifier = UserNameMaxRowHeight,
) {
if (noteEvent is BaseThreadedEvent && noteEvent.isAFork()) {
ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav)
} else {
ObserveDisplayNip05Status(noteAuthor, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav)
Column(modifier = remember(noteEvent) { Modifier.weight(1f) }) {
if (noteEvent is BaseThreadedEvent && noteEvent.isAFork()) {
ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav)
} else {
ObserveDisplayNip05Status(noteAuthor, accountViewModel, nav)
}
}
val geo = remember(noteEvent) { noteEvent.geoHashOrScope() }
@@ -91,10 +91,9 @@ import com.vitorpamplona.amethyst.ui.theme.QuickActionPopupShadow
import com.vitorpamplona.amethyst.ui.theme.SmallestBorder
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import kotlinx.coroutines.launch
private fun lightenColor(
@@ -123,17 +122,13 @@ val externalLinkForNote = { note: Note ->
"https://nostrbounties.com/b/${note.toNAddr()}"
} else if (note.event is PeopleListEvent) {
"https://listr.lol/a/${note.toNAddr()}"
} else if (note.event is AudioTrackEvent) {
"https://zapstr.live/?track=${note.toNAddr()}"
} else if (note.event is FollowListEvent) {
"https://following.space/d/${note.address.dTag}?p=${note.address.pubKeyHex}"
} else {
njumpLink(note.toNAddr())
}
} else {
if (note.event is FileHeaderEvent) {
"https://filestr.vercel.app/e/${note.toNEvent()}"
} else {
njumpLink(note.toNEvent())
}
njumpLink(note.toNEvent())
}
}
@@ -52,7 +52,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.toasts.StringToastMsg
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -102,14 +102,14 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Preview
@Composable
@@ -499,7 +499,7 @@ private fun RenderOptionBeforeVote(
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapVote(
baseNote: Note,
poolOption: PollOption,
@@ -513,14 +513,8 @@ fun ZapVote(
val isLoggedUser by remember { derivedStateOf { accountViewModel.isLoggedUser(baseNote.author) } }
var wantsToZap by remember { mutableStateOf(false) }
var wantsToPay by remember {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf(),
)
}
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var showErrorMessageDialog by remember { mutableStateOf<StringToastMsg?>(null) }
@@ -563,7 +557,6 @@ fun ZapVote(
accountViewModel.zapAmountChoices().size == 1 &&
pollViewModel.isValidInputVoteAmount(accountViewModel.zapAmountChoices().first())
) {
zapStartingTime = TimeUtils.now()
accountViewModel.zap(
baseNote,
accountViewModel.zapAmountChoices().first() * 1000,
@@ -584,12 +577,13 @@ fun ZapVote(
),
) {
if (wantsToZap) {
val context = LocalContext.current
FilteredZapAmountChoicePopup(
baseNote,
accountViewModel,
pollViewModel,
poolOption.option,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZapStarts = { },
onDismiss = {
wantsToZap = false
zappingProgress = 0f
@@ -600,33 +594,17 @@ fun ZapVote(
zappingProgress = 0f
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = { wantsToPay = it },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
showErrorMessageDialog =
StringToastMsg(
stringRes(context, R.string.error_dialog_zap_error),
it.error,
)
}
},
justShowError = {
scope.launch {
showErrorMessageDialog =
StringToastMsg(
stringRes(context, R.string.error_dialog_zap_error),
it.error,
)
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
showErrorMessageDialog = StringToastMsg(stringRes(context, R.string.error_dialog_zap_error), error)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
@@ -114,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
@@ -169,6 +170,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Composable
fun ReactionsRow(
@@ -970,7 +973,7 @@ private fun likeClick(
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapReaction(
baseNote: Note,
grayTint: Color,
@@ -983,12 +986,6 @@ fun ZapReaction(
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf(),
)
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -1022,7 +1019,19 @@ fun ZapReaction(
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onPayViaIntent = { wantsToPay = it },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
}
},
@@ -1053,7 +1062,19 @@ fun ZapReaction(
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = { wantsToPay = it },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
}
@@ -1064,26 +1085,6 @@ fun ZapReaction(
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
}
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
@@ -1095,7 +1096,19 @@ fun ZapReaction(
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = { wantsToPay = it },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
accountViewModel = accountViewModel,
baseNote = baseNote,
)
@@ -353,7 +353,7 @@ fun UpdateZapAmountContent(
postViewModel.copyFromClipboard(it)
}
} catch (e: IllegalArgumentException) {
accountViewModel.toastManager.toast(R.string.invalid_nip47_uri_title, R.string.invalid_nip47_uri_description)
accountViewModel.toastManager.toast(R.string.invalid_nip47_uri_title, R.string.invalid_nip47_uri_description, uri ?: "")
}
},
) {
@@ -26,15 +26,18 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,6 +45,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -64,16 +68,16 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -281,104 +285,115 @@ fun ZapButton(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PayViaIntentDialog(
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
fun PayViaIntentScreen(
paymentId: String,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
onError: (UserBasedErrorMessage) -> Unit,
justShowError: (UserBasedErrorMessage) -> Unit,
nav: INav,
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.manual_zaps), nav::popBack)
},
) { pad ->
val list = accountViewModel.tempManualPaymentCache.get(paymentId)
if (payingInvoices.size == 1) {
val payable = payingInvoices.first()
payViaIntent(payable.invoice, context, onClose) {
onError(UserBasedErrorMessage(it, payable.info.user))
}
} else {
Dialog(
onDismissRequest = onClose,
properties =
DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
if (list == null) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(stringRes(R.string.feed_is_empty))
}
} else {
LazyColumn(
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
) {
SetDialogToEdgeToEdge()
Surface {
Column(modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = onClose)
}
Spacer(modifier = DoubleVertSpacer)
payingInvoices.forEachIndexed { index, payable ->
val paid = remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size10dp),
) {
if (payable.info.user != null) {
BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel)
} else {
DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel)
}
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
if (payable.info.user != null) {
UsernameDisplay(payable.info.user, accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
}
Row {
Text(
text = showAmount((payable.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringRes(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
}
}
Spacer(modifier = DoubleHorzSpacer)
PayButton(isActive = !paid.value) {
payViaIntent(payable.invoice, context, { paid.value = true }) {
justShowError(UserBasedErrorMessage(it, null))
}
}
}
}
) {
itemsIndexed(
accountViewModel.tempManualPaymentCache.get(paymentId) ?: emptyList(),
key = { _: Int, invoice: ZapPaymentHandler.Payable ->
invoice.invoice
},
) { index, payable ->
DisplayPayable(index, payable, accountViewModel)
}
}
}
}
}
@Composable
fun DisplayPayable(
index: Int,
payable: ZapPaymentHandler.Payable,
accountViewModel: AccountViewModel,
) {
val paid = rememberSaveable(payable) { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size10dp),
) {
if (payable.info.user != null) {
BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel)
} else {
DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel)
}
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
if (payable.info.user != null) {
UsernameDisplay(payable.info.user, accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
}
Row {
Text(
text = showAmount((payable.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringRes(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
}
}
Spacer(modifier = DoubleHorzSpacer)
val context = LocalContext.current
PayButton(isActive = !paid.value) {
payViaIntent(payable.invoice, context, { paid.value = true }) {
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(it, payable.info.user),
)
}
}
}
}
fun payViaIntent(
invoice: String,
context: Context,
@@ -86,6 +86,6 @@ private fun DisplayTagList(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) {
nav.nav(Route.Hashtag(firstTag))
nav.nav(Route.Hashtag(firstTag.lowercase()))
}
}
@@ -96,7 +96,7 @@ fun DisplayUncitedHashtags(
unusedHashtags.forEach { hashtag ->
ClickableTextColor(
text = "#$hashtag ",
onClick = { nav.nav(Route.Hashtag(hashtag)) },
onClick = { nav.nav(Route.Hashtag(hashtag.lowercase())) },
linkColor = MaterialTheme.colorScheme.lessImportantLink,
)
}
@@ -55,7 +55,7 @@ fun DisplayHashtagExternalId(
nav: INav,
) {
DisplayHashtagExternalId(externalId.topic) {
nav.nav(Route.Hashtag(externalId.topic))
nav.nav(Route.Hashtag(externalId.topic.lowercase()))
}
}
@@ -175,6 +175,8 @@ class AccountViewModel(
val toastManager = ToastManager()
val feedStates = AccountFeedContentStates(account, viewModelScope)
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
@OptIn(ExperimentalCoroutinesApi::class)
val notificationHasNewItems =
combineTransform(
@@ -49,7 +49,7 @@ open class DiscoverLongFormFeedFilter(
val notes =
LocalCache.addressables.filterIntoSet(LongTextNoteEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is LongTextNoteEvent && params.match(noteEvent)
noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
@@ -67,7 +67,7 @@ open class DiscoverLongFormFeedFilter(
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is LongTextNoteEvent && params.match(noteEvent)
noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays)
}
}
@@ -51,7 +51,7 @@ open class DiscoverChatFeedFilter(
val note = LocalCache.getNoteIfExists(channel.idHex)
val noteEvent = note?.event
if (noteEvent == null || params.match(noteEvent)) {
if (noteEvent == null || params.match(noteEvent, note.relays)) {
note
} else {
null
@@ -75,7 +75,7 @@ open class DiscoverChatFeedFilter(
return collection.mapNotNullTo(HashSet()) { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) {
if (noteEvent is ChannelCreateEvent && params.match(noteEvent, note.relays)) {
if ((LocalCache.getPublicChatChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) {
note
} else {
@@ -86,7 +86,7 @@ open class DiscoverChatFeedFilter(
val channelEvent = channel?.event
if (channel != null &&
(channelEvent == null || (channelEvent is ChannelCreateEvent && params.match(channelEvent)))
(channelEvent == null || (channelEvent is ChannelCreateEvent && params.match(channelEvent, note.relays)))
) {
if ((LocalCache.getPublicChatChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) {
channel
@@ -48,7 +48,7 @@ open class DiscoverFollowSetsFeedFilter(
val notes =
LocalCache.addressables.filterIntoSet(FollowListEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is FollowListEvent && params.match(noteEvent)
noteEvent is FollowListEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
@@ -67,7 +67,7 @@ open class DiscoverFollowSetsFeedFilter(
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is FollowListEvent && params.match(noteEvent)
noteEvent is FollowListEvent && params.match(noteEvent, it.relays)
}
}
@@ -174,8 +174,8 @@ fun RenderFollowSetThumb(
it,
),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = FollowSetImageModifier,
mainImageModifier = FollowSetImageModifier,
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
@@ -72,7 +72,7 @@ open class DiscoverLiveFeedFilter(
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is LiveActivitiesEvent && filterParams.match(noteEvent)
noteEvent is LiveActivitiesEvent && filterParams.match(noteEvent, it.relays)
}
}
@@ -60,7 +60,7 @@ open class DiscoverCommunityFeedFilter(
if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) {
// send unloaded communities to the screen
note
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
note
} else {
null
@@ -84,7 +84,7 @@ open class DiscoverCommunityFeedFilter(
.mapNotNull { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) {
listOf(note)
} else if (noteEvent is CommunityPostApprovalEvent) {
noteEvent.communityAddresses().mapNotNull {
@@ -93,7 +93,7 @@ open class DiscoverCommunityFeedFilter(
if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) {
definitionNote
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) {
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent, definitionNote.relays)) {
definitionNote
} else {
null
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -74,16 +75,19 @@ open class DiscoverNIP89FeedFilter(
fun acceptDVM(note: Note): Boolean {
val noteEvent = note.event
return if (noteEvent is AppDefinitionEvent) {
acceptDVM(noteEvent)
acceptDVM(noteEvent, note.relays)
} else {
false
}
}
fun acceptDVM(noteEvent: AppDefinitionEvent): Boolean {
fun acceptDVM(
noteEvent: AppDefinitionEvent,
relays: List<NormalizedRelayUrl>,
): Boolean {
val filterParams = buildFilterParams(account)
return noteEvent.appMetaData()?.subscription != true &&
filterParams.match(noteEvent) &&
filterParams.match(noteEvent, relays) &&
noteEvent.includeKind(5300) &&
noteEvent.createdAt > lastAnnounced // && params.match(noteEvent)
}
@@ -49,7 +49,7 @@ open class DiscoverMarketplaceFeedFilter(
val notes =
LocalCache.addressables.filterIntoSet(ClassifiedsEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent, it.relays)
}
return sort(notes)
@@ -68,7 +68,7 @@ open class DiscoverMarketplaceFeedFilter(
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent, it.relays)
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -96,6 +97,7 @@ class FollowPackFeedConversationsFeedFilter(
fun acceptableEvent(
event: Event?,
relays: List<NormalizedRelayUrl>,
filterParams: FilterByListParams,
): Boolean =
(
@@ -107,12 +109,12 @@ class FollowPackFeedConversationsFeedFilter(
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event)
filterParams.match(event, relays)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, filterParams) && !note.isNewThread()
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -74,6 +75,7 @@ class HomeConversationsFeedFilter(
fun acceptableEvent(
event: Event?,
relays: List<NormalizedRelayUrl>,
filterParams: FilterByListParams,
): Boolean =
(
@@ -85,12 +87,12 @@ class HomeConversationsFeedFilter(
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event)
filterParams.match(event, relays)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, filterParams) && !note.isNewThread()
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -21,28 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserComposeNoAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier
@@ -94,22 +91,16 @@ fun PeopleListItem(
accountViewModel = accountViewModel,
nav = nav,
)
IconButton(
OutlinedIconButton(
onClick = {
onDeleteUser(user)
},
modifier =
HalfPadding
.align(Alignment.CenterVertically)
.background(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(percent = 80),
),
modifier = HalfPadding,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(20.dp),
imageVector = Icons.Filled.PersonRemove,
contentDescription = stringRes(R.string.remove_user_from_the_list),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -46,9 +45,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
@@ -60,40 +58,46 @@ fun AllPeopleListFeedView(
val peopleListFeedState by peopleListModel.listFlow().collectAsStateWithLifecycle()
val followPackFeedState by followPackModel.listFlow().collectAsStateWithLifecycle()
if (peopleListFeedState.isEmpty() && followPackFeedState.isEmpty()) {
AllPeopleListFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg),
)
} else {
LazyColumn(
state = rememberLazyListState(),
contentPadding = FeedPadding,
) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.PeopleListMetadataEdit())
},
LazyColumn(
state = rememberLazyListState(),
contentPadding = FeedPadding,
) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.PeopleListMetadataEdit())
},
)
}
}
if (peopleListFeedState.isEmpty()) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(
text = stringRes(R.string.feed_is_empty),
modifier = Modifier.padding(vertical = Size20dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
} else {
itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier =
@@ -108,34 +112,46 @@ fun AllPeopleListFeedView(
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.FollowPackMetadataEdit())
},
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.FollowPackMetadataEdit())
},
)
}
}
if (followPackFeedState.isEmpty()) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(
text = stringRes(R.string.feed_is_empty),
modifier = Modifier.padding(vertical = Size20dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
} else {
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier =
@@ -153,17 +169,3 @@ fun AllPeopleListFeedView(
}
}
}
@Composable
fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = Size40dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(message)
Spacer(modifier = StdVertSpacer)
}
}
@@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -48,8 +46,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
@@ -63,44 +61,43 @@ fun FollowListAndPackAndUserView(
val followPackFeedState by accountViewModel.account.followLists.uiListFlow
.collectAsStateWithLifecycle()
if (followSetsState.isEmpty() && followPackFeedState.isEmpty()) {
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = stringRes(R.string.follow_set_empty_dialog_msg))
Spacer(modifier = StdVertSpacer)
}
} else {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
val userName by observeUserName(userToAddOrRemove, accountViewModel)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.PeopleListMetadataEdit()) },
LazyColumn(modifier = Modifier.fillMaxWidth()) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.PeopleListMetadataEdit()) },
)
}
}
if (followSetsState.isEmpty()) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(
text = stringRes(R.string.feed_is_empty),
modifier = Modifier.padding(vertical = Size20dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
} else {
itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list ->
PeopleListAndUserItem(
modifier = Modifier.fillMaxWidth(),
@@ -136,32 +133,44 @@ fun FollowListAndPackAndUserView(
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.FollowPackMetadataEdit()) },
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.FollowPackMetadataEdit()) },
)
}
}
if (followPackFeedState.isEmpty()) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(
text = stringRes(R.string.feed_is_empty),
modifier = Modifier.padding(vertical = Size20dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
} else {
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list ->
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
@@ -72,7 +72,7 @@ class UserProfileGalleryFeedFilter(
return (
(it.event?.pubKey == user.pubkeyHex && (noteEvent is PictureEvent || noteEvent is VideoEvent || (noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasFromEvent())) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
params.match(noteEvent, it.relays) &&
account.isAcceptable(it)
}
@@ -57,7 +57,7 @@ fun TabFollowedTags(
HashtagHeader(
tag = hashtag,
account = accountViewModel,
onClick = { nav.nav(Route.Hashtag(hashtag)) },
onClick = { nav.nav(Route.Hashtag(hashtag.lowercase())) },
)
HorizontalDivider(
thickness = DividerThickness,
@@ -53,13 +53,13 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25Modifier
@@ -127,23 +127,9 @@ fun DrawAdditionalInfo(
)
}
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
ShowQRDialog(
user = user,
accountViewModel = accountViewModel,
onScan = {
dialogOpen = false
nav.nav(it)
},
onClose = { dialogOpen = false },
)
}
IconButton(
modifier = Size25Modifier,
onClick = { dialogOpen = true },
onClick = { nav.nav(Route.QRDisplay(user.pubkeyHex)) },
) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 1),
@@ -43,6 +43,8 @@ fun NIP19QrCodeScanner(
try {
if (it != null) {
onScan(uriToRoute(it, accountViewModel.account))
} else {
onScan(null)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
@@ -1,235 +0,0 @@
/**
* Copyright (c) 2025 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.qrcode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@Preview
@Composable
fun ShowQRDialogPreview() {
val accountViewModel = mockAccountViewModel()
accountViewModel.userProfile().info =
UserMetadata().apply {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
about = "This is the about me"
}
ShowQRDialog(
user = accountViewModel.userProfile(),
accountViewModel = accountViewModel,
onScan = {},
onClose = {},
)
}
@Composable
fun BackButton(onPress: () -> Unit) {
IconButton(
onClick = onPress,
) {
ArrowBackIcon(MaterialTheme.colorScheme.onBackground)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowQRDialog(
user: User,
accountViewModel: AccountViewModel,
onScan: (Route) -> Unit,
onClose: () -> Unit,
) {
var presenting by remember { mutableStateOf(true) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
BackButton(onPress = onClose)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Surface(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
Column {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceAround,
) {
if (presenting) {
Column {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = user.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = user.info?.bestName() ?: "",
tags = user.info?.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
val nip05 = user.nip05()
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
QrCodeDrawer(user.toNostrUri())
}
Row(modifier = Modifier.padding(horizontal = 30.dp)) {
FilledTonalButton(
onClick = { presenting = false },
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.fillMaxWidth().height(50.dp),
) {
Text(text = stringRes(R.string.scan_qr))
}
}
} else {
NIP19QrCodeScanner(accountViewModel) {
if (it == null) {
presenting = true
} else {
onScan(it)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,245 @@
/**
* Copyright (c) 2025 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.qrcode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@Preview
@Composable
fun ShowQRScreenPreview() {
val accountViewModel = mockAccountViewModel()
accountViewModel.userProfile().info =
UserMetadata().apply {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
about = "This is the about me"
}
ShowQRScreen(
pubkey = accountViewModel.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
@Composable
fun BackButton(onPress: () -> Unit) {
IconButton(
onClick = onPress,
) {
ArrowBackIcon(MaterialTheme.colorScheme.onBackground)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowQRScreen(
pubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(pubkey, accountViewModel) { user ->
if (user != null) {
ShowQRScreen(
user = user,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowQRScreen(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(
"",
nav::popBack,
)
},
) { pad ->
Column(
Modifier
.fillMaxSize()
.padding(
start = 20.dp,
end = 20.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShowQRBody(user, accountViewModel, nav)
}
}
}
@Composable
fun ShowQRBody(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
var presenting by remember { mutableStateOf(true) }
if (presenting) {
PresentQR(user, accountViewModel) {
presenting = false
}
} else {
NIP19QrCodeScanner(accountViewModel) {
if (it == null) {
presenting = true
} else {
nav.nav(it)
}
}
}
}
@Composable
fun PresentQR(
user: User,
accountViewModel: AccountViewModel,
switchToScan: () -> Unit,
) {
Column {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = user.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = user.info?.bestName() ?: "",
tags = user.info?.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
val nip05 = user.nip05()
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
QrCodeDrawer(user.toNostrUri())
}
Row(modifier = Modifier.padding(horizontal = 30.dp)) {
FilledTonalButton(
onClick = switchToScan,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.fillMaxWidth().height(50.dp),
) {
Text(text = stringRes(R.string.scan_qr))
}
}
}
@@ -50,8 +50,8 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl> =
account.client
.relayStatusFlow()
.value.available
.availableRelaysFlow()
.value
.sorted()
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
@@ -257,7 +257,7 @@ private fun DisplaySearchResults(
hashTags,
key = { _, item -> "#$item" },
) { _, item ->
HashtagLine(item) { nav.nav(Route.Hashtag(item)) }
HashtagLine(item.lowercase()) { nav.nav(Route.Hashtag(item.lowercase())) }
HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
@@ -505,12 +505,15 @@ private fun FullBleedNoteCompose(
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
baseNote,
Column(
remember { Modifier.weight(1f) },
accountViewModel,
nav,
)
) {
ObserveDisplayNip05Status(
baseNote,
accountViewModel,
nav,
)
}
val geo = remember { noteEvent.geoHashOrScope() }
if (geo != null) {
@@ -311,7 +311,7 @@ private fun RenderAuthorInformation(
nav: INav,
accountViewModel: AccountViewModel,
) {
Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically) {
NoteAuthorPicture(note, Size55dp, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
@@ -327,10 +327,9 @@ private fun RenderAuthorInformation(
VideoUserOptionAction(note, accountViewModel, nav)
}
if (accountViewModel.settings.isCompleteUIMode()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.fillMaxWidth()) {
ObserveDisplayNip05Status(
note.author!!,
Modifier.weight(1f),
accountViewModel,
nav = nav,
)
@@ -126,7 +126,7 @@ class VideoFeedFilter(
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is PictureEvent && acceptanceEvent(noteEvent))
) &&
params.match(noteEvent) &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
@@ -255,6 +255,8 @@ val drawerSpacing = Modifier.padding(top = Size10dp, start = Size25dp, end = Siz
val IconRowTextModifier = Modifier.padding(start = 16.dp)
val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp)
val Width16Space = Modifier.width(Size16dp)
val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth()
val imageHeaderBannerSize = Modifier.fillMaxWidth().height(150.dp)
@@ -104,8 +104,8 @@ private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.
private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f)
private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f)
private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.32f)
private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32f)
private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.42f)
private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.42f)
private val DarkOnBackgroundColorFilter = ColorFilter.tint(DarkColorPalette.onBackground)
private val LightOnBackgroundColorFilter = ColorFilter.tint(LightColorPalette.onBackground)
@@ -345,6 +345,7 @@
<string name="report_dialog_post_report_btn">Odeslat zprávu</string>
<string name="report_dialog_title">Blokovat a nahlásit</string>
<string name="block_only">Blokovat</string>
<string name="manual_zaps">Manuální zap rozdělení</string>
<string name="bookmarks">Záložky</string>
<string name="drafts">Koncepty</string>
<string name="private_bookmarks">Soukromé záložky</string>
@@ -448,6 +449,7 @@
<string name="follow_list_global">Globální</string>
<string name="follow_list_mute_list">Seznam ztlumení</string>
<string name="follow_sets">Seznamy sledování</string>
<string name="follow_sets_explainer">Toto jsou follow seznamy určené pro vaše vlastní použití. Můžete sledovat uživatele soukromě nebo veřejně.</string>
<string name="labeled_bookmarks">Označené záložky</string>
<string name="general_bookmarks">Obecné záložky</string>
<string name="follow_set_type_public">Veřejné</string>
@@ -480,6 +482,7 @@
<string name="follow_set_creation_item_label">Vytvořit nový seznam %1$s s uživatelem</string>
<string name="follow_set_creation_item_description">Vytvoří %1$s sadu sledování a přidá do ní %2$s.</string>
<string name="follow_set_creation_dialog_title">Nový seznam sledování</string>
<string name="follow_pack_creation_dialog_title">Nová sada doporučení</string>
<string name="follow_set_copy_dialog_title">Kopírovat/Klonovat seznam sledování</string>
<string name="follow_set_desc_modify_label">Upravit popis</string>
<string name="follow_set_empty_desc_label">Tento seznam nemá žádný popis</string>
@@ -492,6 +495,7 @@
<string name="follow_set_creation_action_btn_label">Vytvořit seznam</string>
<string name="follow_set_copy_action_btn_label">Kopírovat/Klonovat seznam</string>
<string name="follow_set_rename_btn_label">Přejmenovat seznam</string>
<string name="follow_set_edit_list_metadata">Upravit seznam</string>
<string name="follow_set_desc_modify_btn_label">Upravit</string>
<string name="follow_set_rename_dialog_indicator_first_part">Přejmenováváte z </string>
<string name="follow_set_rename_dialog_indicator_second_part"> na..</string>
@@ -630,6 +634,7 @@
<string name="followed_tags">Sledované značky</string>
<string name="relay_setup">Rele</string>
<string name="discover_follows">Balíčky Sledování</string>
<string name="discover_follows_explainer">Toto jsou seznamy uživatelů, které doporučujete ostatním. Jsou povoleni pouze veřejní uživatelé.</string>
<string name="discover_reads">Přečtené</string>
<string name="discover_content_v2">Algoritmy kanálu</string>
<string name="discover_marketplace">Trh</string>
@@ -1121,4 +1126,17 @@
<string name="remove_user_from_the_list">Odebrat uživatele ze seznamu</string>
<string name="follow_list_item_label">Balíček Sledování</string>
<string name="members">Členové</string>
<string name="people_list_title">Metadata seznamu sledování</string>
<string name="people_list_explainer">Metadata seznamů sledování mohou vidět všichni na Nostr. Pouze vaši soukromí členové jsou šifrovaní.</string>
<string name="follow_pack_title">Metadata sady doporučení</string>
<string name="follow_pack_explainer">Metadata sad doporučení mohou vidět všichni na Nostr a často jsou zveřejňována na různých webech jako startovací sady pro nové uživatele.</string>
<string name="follow_pack_edit_list_metadata">Upravit sadu doporučení</string>
<string name="follow_pack_creation_name_label">Název sady</string>
<string name="follow_pack_copy_name_label">Nový název sady</string>
<string name="follow_pack_creation_desc_label">Popis sady</string>
<string name="follow_pack_copy_desc_label">Nový popis sady</string>
<string name="follow_set_broadcast">Broadcast seznamu</string>
<string name="follow_pack_broadcast">Broadcast sady doporučení</string>
<string name="follow_set_delete">Smazat seznam</string>
<string name="follow_pack_delete">Smazat sadu doporučení</string>
</resources>
@@ -351,6 +351,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="report_dialog_post_report_btn">Meldung veröffentlichen</string>
<string name="report_dialog_title">Blockieren und melden</string>
<string name="block_only">Blockieren</string>
<string name="manual_zaps">Manuelle Zap-Splits</string>
<string name="bookmarks">Lesezeichen</string>
<string name="drafts">Entwürfe</string>
<string name="private_bookmarks">Private Lesezeichen</string>
@@ -454,6 +455,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_list_global">Weltweit</string>
<string name="follow_list_mute_list">Stummliste</string>
<string name="follow_sets">Follower-Listen</string>
<string name="follow_sets_explainer">Dies sind Follow-Listen für deine eigene Nutzung. Du kannst Nutzer privat oder öffentlich folgen.</string>
<string name="labeled_bookmarks">Markierte Lesezeichen</string>
<string name="general_bookmarks">Allgemeine Lesezeichen</string>
<string name="follow_set_type_public">Öffentlich</string>
@@ -486,6 +488,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_creation_item_label">Neue %1$s-Liste mit Benutzer erstellen</string>
<string name="follow_set_creation_item_description">Erstellt ein %1$s-Folge-Set und fügt %2$s hinzu.</string>
<string name="follow_set_creation_dialog_title">Neue Follower-Liste</string>
<string name="follow_pack_creation_dialog_title">Neues Empfehlungspaket</string>
<string name="follow_set_copy_dialog_title">Follower-Liste kopieren/klonen</string>
<string name="follow_set_desc_modify_label">Beschreibung bearbeiten</string>
<string name="follow_set_empty_desc_label">Diese Liste hat keine Beschreibung</string>
@@ -498,6 +501,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_creation_action_btn_label">Liste erstellen</string>
<string name="follow_set_copy_action_btn_label">Liste kopieren/klonen</string>
<string name="follow_set_rename_btn_label">Liste umbenennen</string>
<string name="follow_set_edit_list_metadata">Liste bearbeiten</string>
<string name="follow_set_desc_modify_btn_label">Bearbeiten</string>
<string name="follow_set_rename_dialog_indicator_first_part">Du benennst um von </string>
<string name="follow_set_rename_dialog_indicator_second_part"> zu..</string>
@@ -635,6 +639,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="followed_tags">Gefolgte Tags</string>
<string name="relay_setup">Relais</string>
<string name="discover_follows">Folge Paketen</string>
<string name="discover_follows_explainer">Dies sind Listen von Nutzern, die du anderen empfiehlst. Nur öffentliche Nutzer sind erlaubt.</string>
<string name="discover_reads">Lesungen</string>
<string name="discover_content_v2">Feedalgorithmen</string>
<string name="discover_marketplace">Marktplatz</string>
@@ -1126,4 +1131,17 @@ anz der Bedingungen ist erforderlich</string>
<string name="remove_user_from_the_list">Benutzer aus der Liste entfernen</string>
<string name="follow_list_item_label">Folge Paket</string>
<string name="members">Mitglieder</string>
<string name="people_list_title">Metadaten der Follow-Liste</string>
<string name="people_list_explainer">Metadaten von Follow-Listen können von allen auf Nostr gesehen werden. Nur deine privaten Mitglieder sind verschlüsselt.</string>
<string name="follow_pack_title">Metadaten des Empfehlungspakets</string>
<string name="follow_pack_explainer">Metadaten von Empfehlungspaketen können von allen auf Nostr gesehen werden und werden häufig auf Websites als Starterkits für neue Nutzer veröffentlicht.</string>
<string name="follow_pack_edit_list_metadata">Empfehlungspaket bearbeiten</string>
<string name="follow_pack_creation_name_label">Paketname</string>
<string name="follow_pack_copy_name_label">Neuer Paketname</string>
<string name="follow_pack_creation_desc_label">Paketbeschreibung</string>
<string name="follow_pack_copy_desc_label">Neue Paketbeschreibung</string>
<string name="follow_set_broadcast">Liste veröffentlichen</string>
<string name="follow_pack_broadcast">Empfehlungspaket veröffentlichen</string>
<string name="follow_set_delete">Liste löschen</string>
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
</resources>
@@ -112,6 +112,7 @@
<string name="post">Envoyer</string>
<string name="save">Sauvegarder</string>
<string name="create">Créer</string>
<string name="rename">Renommer</string>
<string name="cancel">Annuler</string>
<string name="failed_to_upload_the_image">Échec du téléchargement de l\'image</string>
<string name="relay_address">Adresse du relais</string>
@@ -124,6 +125,8 @@
<string name="public_chat_feed">Flux de chat public</string>
<string name="global_feed">Flux mondial</string>
<string name="search_feed">Flux de recherche</string>
<string name="search_and_add_a_user">Rechercher et ajouter un utilisateur</string>
<string name="add_a_user">Ajouter un utilisateur</string>
<string name="add_a_relay">Ajouter un relais</string>
<string name="profile_name">Nom</string>
<string name="display_name">Nom visible du public</string>
@@ -344,6 +347,7 @@
<string name="report_dialog_post_report_btn">Publier le signalement</string>
<string name="report_dialog_title">Bloquer et Signaler</string>
<string name="block_only">Bloquer</string>
<string name="manual_zaps">Division manuelle de zaps</string>
<string name="bookmarks">Favoris</string>
<string name="drafts">Brouillons</string>
<string name="private_bookmarks">Favoris Privés</string>
@@ -445,6 +449,46 @@
<string name="follow_list_aroundme">Autour de moi</string>
<string name="follow_list_global">Général</string>
<string name="follow_list_mute_list">Liste en Sourdine</string>
<string name="labeled_bookmarks">Marque-pages étiquetés</string>
<string name="general_bookmarks">Marque-pages généraux</string>
<string name="follow_set_type_public">Public</string>
<string name="follow_set_type_private">Privé</string>
<string name="follow_set_type_mixed">Mixte</string>
<string name="follow_set_create_btn_label">Nouveau</string>
<string name="follow_set_public_presence_indicator">%1$s est un membre public</string>
<string name="follow_set_private_presence_indicator">%1$s est un membre privé</string>
<string name="follow_set_public_member_add_label">Ajouter en tant que membre public</string>
<string name="follow_set_private_member_add_label">Ajouter en tant que membre privé</string>
<string name="follow_set_public_members_header_label2">Membres publics</string>
<string name="follow_set_private_members_header_label2">Membres privés</string>
<string name="follow_set_public_members_header_label">Profils publics</string>
<string name="follow_set_private_members_header_label">Profils privés</string>
<string name="follow_set_single_member_label">membre</string>
<string name="follow_set_multiple_member_label">membres</string>
<string name="follow_set_empty_label">Aucun membre</string>
<string name="follow_set_empty_label2">Vide</string>
<string name="follow_set_absence_indicator">%1$s n\'est pas dans cette liste</string>
<string name="follow_set_absence_indicator2">%1$s n\'est pas un membre</string>
<string name="follow_set_man_dialog_title2">Vos listes et %1$s</string>
<string name="follow_set_man_dialog_title">Vos listes</string>
<string name="follow_set_error_dialog_msg">Un problème est survenu lors de la récupération : %1$s</string>
<string name="follow_set_creation_menu_title">Créer une nouvelle liste</string>
<string name="follow_set_creation_item_label">Nouvelle liste avec %1$s membres</string>
<string name="follow_set_desc_modify_label">Modifier la description</string>
<string name="follow_set_empty_desc_label">Cette liste n\'a pas de description</string>
<string name="follow_set_current_desc_label">Description actuelle :</string>
<string name="follow_set_copy_indicator_description">Définissez un nouveau nom/description pour la liste dupliquée ci-dessous.</string>
<string name="follow_set_creation_name_label">Nom de la liste</string>
<string name="follow_set_copy_name_label">Nom de la nouvelle liste</string>
<string name="follow_set_creation_desc_label">Description de la liste (facultatif)</string>
<string name="follow_set_copy_desc_label">Description de la nouvelle liste</string>
<string name="follow_set_creation_action_btn_label">Créer une liste</string>
<string name="follow_set_copy_action_btn_label">Copier/Dupliquer la liste</string>
<string name="follow_set_rename_btn_label">Renommer la liste</string>
<string name="follow_set_edit_list_metadata">Modifier la liste</string>
<string name="follow_set_desc_modify_btn_label">Modifier</string>
<string name="follow_set_rename_dialog_indicator_first_part">Vous renommez depuis </string>
<string name="follow_set_rename_dialog_indicator_second_part"> vers..</string>
<string name="connect_through_your_orbot_setup_short">Le port par défaut est 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Se connecter à travers Tor avec Orbot
\n\n1. Installe [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -580,6 +624,7 @@
<string name="followed_tags">Tags suivis</string>
<string name="relay_setup">Relais</string>
<string name="discover_follows">Packs à suivre</string>
<string name="discover_follows_explainer">Ce sont des listes d\'utilisateurs que vous recommandez à d\'autres personnes. Seuls les utilisateurs publics sont autorisés.</string>
<string name="discover_reads">Lectures</string>
<string name="discover_content_v2">Algorithmes de flux</string>
<string name="discover_marketplace">Place de Marché</string>
@@ -661,6 +706,8 @@
<string name="paste_from_clipboard">Coller depuis le presse-papiers</string>
<string name="invalid_nip47_uri_title">URI NIP-47 invalide</string>
<string name="invalid_nip47_uri_description">L\'URI %1$s n\'est pas une URI de connexion NIP-47 valide.</string>
<string name="connect_to_new_nwc_wallet">Se connecter à un nouveau portefeuille NWC</string>
<string name="couldnt_find_nwc_wallets">Portefeuille introuvable</string>
<string name="language_description">Pour l\'interface de l\'App</string>
<string name="theme_description">Sombre, Clair ou thème Système</string>
<string name="automatically_load_images_gifs_description">Charger automatiquement les images et les GIFs</string>
@@ -832,6 +879,8 @@
<string name="media_compression_quality_medium">Moyenne</string>
<string name="media_compression_quality_high">Haute</string>
<string name="media_compression_quality_uncompressed">Non compressé</string>
<string name="video_codec_h265_label">Utiliser le codec H.265/HEVC</string>
<string name="video_codec_h265_description">Une meilleure qualité avec des tailles de fichier plus petites, mais tous les appareils ne supportent pas la lecture H.265.</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>
@@ -890,6 +939,15 @@
<string name="search_relays_not_found_description">La création d\'une liste de relais spécialement conçue pour la recherche et l\'étiquetage utilisateur améliorera ces résultats.</string>
<string name="search_relays_not_found_editing">Insérez de 1 à 3 relais à utiliser lors de la recherche de contenu ou de l\'étiquetage utilisateur. Assurez-vous que les relais que vous avez choisis implémentent le NIP-50</string>
<string name="search_relays_not_found_examples">De bonnes options sont:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Relais d\'envoi</string>
<string name="outbox_relays_not_found">Configurez vos relais d\'envoi pour publier</string>
<string name="outbox_relays_not_found_description">Créer une liste de relais spécialement conçue pour recevoir votre contenu est crucial pour votre expérience Nostr et la seule façon que vos abonnés puissent vous trouver. </string>
<string name="outbox_relays_not_found_editing">Insérez entre 1 et 3 relais qui reçoivent vos messages. Assurez-vous qu\'ils ne nécessitent pas de paiement si vous ne payez pas pour publier</string>
<string name="outbox_relays_not_found_examples">De bonnes options sont :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_not_found">Configurez vos relais de messagerie publique pour recevoir des notifications</string>
<string name="inbox_relays_not_found_description">Créer une liste de relais spécialement conçue pour recevoir des notifications est crucial pour votre expérience Nostr. </string>
<string name="inbox_relays_not_found_editing">Insérez entre 1 et 3 relais qui reçoivent des notifications pour vous. Assurez-vous qu\'ils n\'exigent pas de paiement ou de web de confiance de la part de l\'expéditeur, sinon vous ne recevrez que des notifications des payeurs ou des utilisateurs à long terme</string>
<string name="inbox_relays_not_found_examples">De bonnes options sont :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Téléversement de MP</string>
<string name="relay_settings">Paramètres du relais</string>
<string name="public_home_section">Relais publics d\'accueil</string>
@@ -1014,6 +1072,9 @@
<string name="torrent_download">Télécharger</string>
<string name="torrent_failure">Échec de l\'ouverture du fichier</string>
<string name="torrent_no_apps">Aucune application torrent installée pour ouvrir et télécharger le fichier.</string>
<string name="torrent_no_info">L\'événement ne possède pas assez d\'informations pour construire un lien magnet</string>
<string name="my_lists">Mes listes</string>
<string name="people_list_label">Utilisateurs</string>
<string name="select_list_to_filter">Sélectionnez une liste pour filtrer le fil</string>
<string name="temporary_account">Se déconnecter au verrouillage de l\'appareil</string>
<string name="private_message">Message privé</string>
@@ -1035,4 +1096,20 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Voulez-vous envoyer le rapport d\'erreur récent à Amethyst dans un MP ? Aucune information personnelle ne sera partagée</string>
<string name="crashreport_found_send">Envoyer</string>
<string name="this_message_will_disappear_in_days">Ce message disparaîtra dans %1$d jours</string>
<string name="in_the_list">Déjà dans la liste</string>
<string name="private_members">Membres privés</string>
<string name="public_members">Membres publics</string>
<string name="private_members_count">Membres privés (%1$s)</string>
<string name="public_members_count">Membres publics (%1$s)</string>
<string name="add_user_to_the_list">Ajouter l\'utilisateur à la liste</string>
<string name="remove_user_from_the_list">Supprimer l\'utilisateur de la liste</string>
<string name="members">Membres</string>
<string name="follow_pack_creation_name_label">Nom du pack</string>
<string name="follow_pack_copy_name_label">Nom du nouveau pack</string>
<string name="follow_pack_creation_desc_label">Description du pack</string>
<string name="follow_pack_copy_desc_label">Description du nouveau pack</string>
<string name="follow_set_broadcast">Liste de diffusion</string>
<string name="follow_pack_broadcast">Pack de diffusion</string>
<string name="follow_set_delete">Supprimer la liste</string>
<string name="follow_pack_delete">Supprimer le pack</string>
</resources>
@@ -347,6 +347,7 @@
<string name="report_dialog_post_report_btn">सूचना प्रकाशित करें</string>
<string name="report_dialog_title">बाधित करें तथा सूचना दें</string>
<string name="block_only">बाधित करें</string>
<string name="manual_zaps">मनुष्यकृत ज्साप विभाजन</string>
<string name="bookmarks">स्मर्तव्य</string>
<string name="drafts">पाण्डुलिपियाँ</string>
<string name="private_bookmarks">निजी स्मर्तव्य</string>
@@ -449,7 +450,8 @@
<string name="follow_list_aroundme">मेरे आसपास</string>
<string name="follow_list_global">वैश्विक</string>
<string name="follow_list_mute_list">मौन सूची</string>
<string name="follow_sets">अनुगम्य सूचियाँ</string>
<string name="follow_sets">अनुचरण सूचियाँ</string>
<string name="follow_sets_explainer">ये अनुचरण सूचियाँ आपके उपयोग के लिए रूपांकित हैं। आप प्रयोक्ताओं का अनुचरण कर सकते हैं निजी अथवा सार्वजनिक रूप से</string>
<string name="labeled_bookmarks">सूचक युक्त स्मर्त्तव्य सूची</string>
<string name="general_bookmarks">सामान्य स्मर्त्तव्य सूची</string>
<string name="follow_set_type_public">सार्वजनिक</string>
@@ -459,9 +461,9 @@
\nनवीकरण के लिए नीचे दबाएँ। अथवा एक नया बनाने के लिए जोड घुण्डियाँ टाँकें।
</string>
<string name="follow_set_create_btn_label">नव्य</string>
<string name="follow_set_add_author_from_note_action">लेखक जोडें अनुगम्य सूची में</string>
<string name="follow_set_add_author_from_note_action">लेखक जोडें अनुचरण सूची में</string>
<string name="follow_set_profile_actions_menu_description">प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ।</string>
<string name="follow_set_icon_description">सूची %1$s के लिए चिह्न</string>
<string name="follow_set_icon_description">अनुचरण सूची के लिए चिह्न</string>
<string name="follow_set_public_presence_indicator">%1$s एक सार्वजनिक सदस्य है</string>
<string name="follow_set_private_presence_indicator">%1$s एक निजी सदस्य है</string>
<string name="follow_set_public_member_add_label">सार्वजनिक सदस्य के रूप में जोडें</string>
@@ -484,6 +486,7 @@
<string name="follow_set_creation_item_label">प्रयोक्ता के साथ नई सूची %1$s बनाएँ</string>
<string name="follow_set_creation_item_description">अनुगम्य सूची %1$s बनाता है तथा उससे %2$s जोडता है।</string>
<string name="follow_set_creation_dialog_title">नई %1$s सूची</string>
<string name="follow_pack_creation_dialog_title">नव्य अनुचरण पोटली</string>
<string name="follow_set_copy_dialog_title">अनुचरण सूची की अनुकृति करें</string>
<string name="follow_set_desc_modify_label">विवरण का संशोधन करें</string>
<string name="follow_set_empty_desc_label">इस सूची का कोई विवरण नहीं</string>
@@ -496,6 +499,7 @@
<string name="follow_set_creation_action_btn_label">समुच्चय बनाएँ</string>
<string name="follow_set_copy_action_btn_label">सूची की अनुकृति</string>
<string name="follow_set_rename_btn_label">समुच्चय पुनःनामकरण</string>
<string name="follow_set_edit_list_metadata">सूची सम्पादन</string>
<string name="follow_set_desc_modify_btn_label">संशोधन करें</string>
<string name="follow_set_rename_dialog_indicator_first_part">आप पनःनामकरण कर रहे हैं इस से </string>
<string name="follow_set_rename_dialog_indicator_second_part"> इस तक..</string>
@@ -634,6 +638,7 @@
<string name="followed_tags">अनुचरित विषयसूचक</string>
<string name="relay_setup">पुनःप्रसारक</string>
<string name="discover_follows">अनुचरण पोटलियाँ</string>
<string name="discover_follows_explainer">ये प्रयोक्ता सूचियाँ हैं जो आप अन्य लोगों के लिए अनुशंसित करते हैं। केवल सार्वजनिक प्रयोक्ता अनुमत।</string>
<string name="discover_reads">पठितव्य</string>
<string name="discover_content_v2">सूचनावली विधियाँ</string>
<string name="discover_marketplace">पण्यक्षेत्र</string>
@@ -1126,4 +1131,17 @@
<string name="remove_user_from_the_list">सूची से प्रयोक्ता हटाएँ</string>
<string name="follow_list_item_label">अनुचरण पोटली</string>
<string name="members">सदस्य सूची</string>
<string name="people_list_title">अनुचरण सूची उपतथ्य</string>
<string name="people_list_explainer">अनुचरण सूची उपतथ्य नोस्टर में सब के द्वारा देखा जा सकता है। केवल आपके निजी सदस्य रहस्यीकृत हैं।</string>
<string name="follow_pack_title">अनुचरण पोटली उपतथ्य</string>
<string name="follow_pack_explainer">अनुचरण पोटली उपतथ्य नोस्टर में सब के द्वारा देखा जा सकता है तथा प्रायः अनेक जालस्थानों पर प्रकाशित होते हैं प्रारम्भ सामग्री के रूप में नये प्रयोक्ताओं के लिए।</string>
<string name="follow_pack_edit_list_metadata">अनुचरण पोटली सम्पादन</string>
<string name="follow_pack_creation_name_label">पोटली नाम</string>
<string name="follow_pack_copy_name_label">नव्य पोटली नाम</string>
<string name="follow_pack_creation_desc_label">पोटली विवरण</string>
<string name="follow_pack_copy_desc_label">नव्य पोटली विवरण</string>
<string name="follow_set_broadcast">सूची प्रसारण</string>
<string name="follow_pack_broadcast">पोटली प्रसारण</string>
<string name="follow_set_delete">सूची मिटाएँ</string>
<string name="follow_pack_delete">पोटली मिटाएँ</string>
</resources>
+51 -27
View File
@@ -115,7 +115,7 @@
<string name="rename">Átnevezés</string>
<string name="cancel">Mégse</string>
<string name="failed_to_upload_the_image">Nem sikerült feltölteni a képet</string>
<string name="relay_address">Atjátszó címe</string>
<string name="relay_address">Átjátszó címe</string>
<string name="posts">Bejegyzések</string>
<string name="bytes">Bájt</string>
<string name="errors">Hibák</string>
@@ -347,6 +347,7 @@
<string name="report_dialog_post_report_btn">Bejelentés közzététele</string>
<string name="report_dialog_title">Letiltás és bejelentés</string>
<string name="block_only">Letiltás</string>
<string name="manual_zaps">Kézi Zap-megosztás</string>
<string name="bookmarks">Könyvjelző</string>
<string name="drafts">Piszkozatok</string>
<string name="private_bookmarks">Privát könyvjelzők</string>
@@ -427,7 +428,7 @@
<string name="uploading_state_uploading">Feltöltés…</string>
<string name="uploading_state_server_processing">Feldolgozás…</string>
<string name="uploading_state_downloading">Letöltés…</string>
<string name="uploading_state_hashing">Hash-selés…</string>
<string name="uploading_state_hashing">Kivonatolás</string>
<string name="uploading_state_finished">Kész</string>
<string name="uploading_state_error">Hiba</string>
<string name="upload_server_relays_nip95">Saját átjátszók (NIP-95)</string>
@@ -444,11 +445,13 @@
<string name="follow_list_selection">Követési lista</string>
<string name="follow_list_kind3follows">Követettek bejegyzései</string>
<string name="follow_list_kind3follows_users_only">Összes követett felhasználó</string>
<string name="follow_list_kind3_follows_users_only">Alapértelmezett követési lista</string>
<string name="follow_list_kind3follows_proxy">Követés proxyn keresztül</string>
<string name="follow_list_aroundme">Közelben lévők bejegyzései</string>
<string name="follow_list_global">Globális</string>
<string name="follow_list_mute_list">Némítottak bejegyzései</string>
<string name="follow_sets">Követési gyüjtemények</string>
<string name="follow_sets">Követési listák</string>
<string name="follow_sets_explainer">Ezek a követési listák az Ön saját alkalmazás-használatából lettek kialakítva. A felhasználókat privát vagy nyilvánosan követheti</string>
<string name="labeled_bookmarks">Címkézett könyvjelzők</string>
<string name="general_bookmarks">Általános könyvjelzők</string>
<string name="follow_set_type_public">Nyílvános</string>
@@ -458,14 +461,14 @@
\nKoppintson a frissítéshez vagy érintse meg a hozzáadás gombot a gyüjtemény létrehozásához.
</string>
<string name="follow_set_create_btn_label">Új</string>
<string name="follow_set_add_author_from_note_action">Szerző hozzáadása a követési gyűjteményhez</string>
<string name="follow_set_add_author_from_note_action">Szerző hozzáadása a követési listához</string>
<string name="follow_set_profile_actions_menu_description">Felhasználó hozzáadása vagy eltávolítása a listákból, vagy új lista létrehozása ezzel a felhasználóval.</string>
<string name="follow_set_icon_description">Ikon a(z) %1$s nevű listához</string>
<string name="follow_set_icon_description">Ikon a követési listához</string>
<string name="follow_set_public_presence_indicator">%1$s egy nyílvános tag</string>
<string name="follow_set_private_presence_indicator">%1$s egy privát tag</string>
<string name="follow_set_public_member_add_label">Hozzáadás mint nyílvános tag</string>
<string name="follow_set_private_member_add_label">Hozzáadás mint privát tag</string>
<string name="follow_set_public_members_header_label2">Publikus Tagok</string>
<string name="follow_set_public_members_header_label2">Nyilvános tagok</string>
<string name="follow_set_private_members_header_label2">Privát Tagok</string>
<string name="follow_set_public_members_header_label">Nyílvános profilok</string>
<string name="follow_set_private_members_header_label">Privát profilok</string>
@@ -476,25 +479,27 @@
<string name="follow_set_absence_indicator">A(z) %1$s nincs a listában</string>
<string name="follow_set_absence_indicator2">%1$s nem tag</string>
<string name="follow_set_man_dialog_title2">Saját listák és %1$s</string>
<string name="follow_set_man_dialog_title">Saját követési gyüjtemények</string>
<string name="follow_set_empty_dialog_msg">Nem találhatók követési gyüjtemények, vagy nincs követési gyüjteménye. Érintse meg az alábbi gombot a frissítéshez vagy használja a menüt egy gyüjtemény létrehozásához.</string>
<string name="follow_set_man_dialog_title">Saját listák</string>
<string name="follow_set_empty_dialog_msg">Nem találhatók vagy Önnek még nincs követési listája. Koppintson az alábbi gombra a frissítéshez vagy használja a menüt egy új lista létrehozásához.</string>
<string name="follow_set_error_dialog_msg">Probléma történt a következő lekérdezésekor: %1$s</string>
<string name="follow_set_creation_menu_title">Új lista létrehozása</string>
<string name="follow_set_creation_item_label">Új %1$s lista létrehozása felhasználóval</string>
<string name="follow_set_creation_item_description">%1$s követési gyüjtemény létrehozása, és hozzáadás a következhöz: %2$s.</string>
<string name="follow_set_creation_dialog_title">Új %1$s lista</string>
<string name="follow_set_copy_dialog_title">Követési gyüjtemény másolása/klónozása</string>
<string name="follow_set_creation_item_label">Új lista %1$s tagsággal</string>
<string name="follow_set_creation_item_description">Egy új követési gyüjtemény létrehozása, és %1$s hozzáadása mint %2$s tag.</string>
<string name="follow_set_creation_dialog_title">Új követési lista</string>
<string name="follow_pack_creation_dialog_title">Új követési lista</string>
<string name="follow_set_copy_dialog_title">Követési lista másolása/klónozása</string>
<string name="follow_set_desc_modify_label">Leírás módosítása</string>
<string name="follow_set_empty_desc_label">Ennek a listának nincs leírása</string>
<string name="follow_set_current_desc_label">Jelenlegi leírás:</string>
<string name="follow_set_copy_indicator_description">Az alábbiakban beállíthat egy egyéni nevet/leírást ehhez a klónozott követési gyűjteményhez.</string>
<string name="follow_set_creation_name_label">Név megadása</string>
<string name="follow_set_copy_name_label">(Gyüjtemény eredeti neve)</string>
<string name="follow_set_creation_desc_label">Leírás megadása (nem kötelező)</string>
<string name="follow_set_copy_desc_label">(Gyüjtemény eredeti leírása)</string>
<string name="follow_set_creation_action_btn_label">Gyüjtemény létrehozása</string>
<string name="follow_set_copy_action_btn_label">Gyüjtemény másolása/klónozása</string>
<string name="follow_set_rename_btn_label">Gyüjtemény átnevezése</string>
<string name="follow_set_copy_indicator_description">Az alábbiakban beállíthat egy új nevet/leírást ehhez a klónozott követési listához.</string>
<string name="follow_set_creation_name_label">Listanév</string>
<string name="follow_set_copy_name_label">Új listanév</string>
<string name="follow_set_creation_desc_label">Listaleírás (nem kötelező)</string>
<string name="follow_set_copy_desc_label">Új listaleírás</string>
<string name="follow_set_creation_action_btn_label">Lista létrehozása</string>
<string name="follow_set_copy_action_btn_label">Lista másolása/klónozása</string>
<string name="follow_set_rename_btn_label">Lista átnevezése</string>
<string name="follow_set_edit_list_metadata">Lista szerkesztése</string>
<string name="follow_set_desc_modify_btn_label">Módosítás</string>
<string name="follow_set_rename_dialog_indicator_first_part">Ön átnevezi a követési gyüjteményt erről: </string>
<string name="follow_set_rename_dialog_indicator_second_part"> erre:</string>
@@ -633,6 +638,7 @@
<string name="followed_tags">Követett címke</string>
<string name="relay_setup">Átjátszók</string>
<string name="discover_follows">Követett csomagok</string>
<string name="discover_follows_explainer">Ezek a listák azon felhasználók listái, akiket másoknak ajánlhat. Csak nyilvános felhasználók szerepelhetnek a listán.</string>
<string name="discover_reads">Olvasmányok</string>
<string name="discover_content_v2">Hírforrás-algoritmus</string>
<string name="discover_marketplace">Piac</string>
@@ -687,7 +693,7 @@
<string name="zap_forward_title">Zap-ek továbbítása neki:</string>
<string name="zap_forward_explainer">A funkciót támogató kliensek a Zap-eket az alábbi LN-címre vagy felhasználói profilra továbbítják az Öné helyett</string>
<string name="geohash_title">A helyszín megjelenítése mint </string>
<string name="geohash_explainer">Hozzáadja a helyének geohash-sét a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik</string>
<string name="geohash_explainer">Hozzáadja a helyszínének geokivonatát a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik</string>
<string name="geohash_exclusive">Helyszín-alapú bejegyzés</string>
<string name="geohash_exclusive_explainer">Csak a helyszín követői láthatják. Az általános követők nem fogják látni.</string>
<string name="hashtag_exclusive">Hashtag-exkluzív bejegyzés</string>
@@ -714,15 +720,15 @@
<string name="paste_from_clipboard">Beszúrás vágólapról</string>
<string name="invalid_nip47_uri_title">Érvénytelen NIP-47 URI</string>
<string name="invalid_nip47_uri_description">A(z) %1$s URI nem egy érvényes NIP-47 bejelentkezési URI.</string>
<string name="connect_to_new_nwc_wallet">Csatlakozás az új NWC tárcához</string>
<string name="couldnt_find_nwc_wallets">Tárca nem található</string>
<string name="couldnt_find_nwc_wallets_description">Az Amethyst nem talált olyan pénztárcaalkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást.
<string name="connect_to_new_nwc_wallet">Kapcsolódás az új NWC-pénztárcához</string>
<string name="couldnt_find_nwc_wallets">Nem található pénztárca</string>
<string name="couldnt_find_nwc_wallets_description">Az Amethyst nem talált olyan pénztárca-alkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást.
\n\nAmit tehet:
\n\t1. NWC támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárcaalkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással.
\n\t1. NWC-támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárca-alkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással.
\n\t2. Kapcsolat létrehozása: Nyissa meg a pénztárcaalkalmazást, és hozzon létre egy új NWC-kapcsolatot.
\n\t3. Kapcsolódás az Amethysthez: A pénztárcája általában három lehetőséget kínál:
\n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és csatlakoztatásához. Ha igen, kattintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn.
\n\t\t3.2 Manuális kapcsolódás: Egy kapcsolat URl, amelyet kimásolhat, és a képernyőn található beillesztés ikonra kattintva közvetlenül beírhatja.
\n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és kapcsolódáshoz. Koppintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn.
\n\t\t3.2 Kézi kapcsolódás: Egy kapcsolódási cím, amelyet kimásolhat, és a képernyőn található beillesztés gombra koppintva közvetlenül beírhatja.
\n\t\t3.3 QR-kód: A pénztárca létrehoz egy QR-kódot, amelyet a képernyőn található QR-kód gombról olvashat be.
</string>
<string name="language_description">Az alkalmazás felületéhez</string>
@@ -1119,5 +1125,23 @@
<string name="in_the_list">Már rajta van a listán</string>
<string name="private_members">Privát tagok</string>
<string name="public_members">Nyilvános tagok</string>
<string name="private_members_count">Privát tagok (%1$s)</string>
<string name="public_members_count">Nyilvános tagok (%1$s)</string>
<string name="add_user_to_the_list">Felhasználó hozzáadása a listához</string>
<string name="remove_user_from_the_list">Felhasználó törlése a listáról</string>
<string name="follow_list_item_label">Követési csomag</string>
<string name="members">Tagok</string>
<string name="people_list_title">Követési lista metaadatai</string>
<string name="people_list_explainer">A követési listák metaadatai a Nostr-on bárki számára láthatók. Csak a privát tagok adatai vannak titkosítva.</string>
<string name="follow_pack_title">Követési csomag metaadatai</string>
<string name="follow_pack_explainer">A követési csomag metaadatai a Nostr-on bárki számára láthatók, és gyakran megjelennek számos weboldalon, új felhasználók számára készült kezdőcsomagként.</string>
<string name="follow_pack_edit_list_metadata">Követési csomag szerkesztése</string>
<string name="follow_pack_creation_name_label">Csomagnév</string>
<string name="follow_pack_copy_name_label">Új csomagnév</string>
<string name="follow_pack_creation_desc_label">Csomagleírás</string>
<string name="follow_pack_copy_desc_label">Új csomagleírás</string>
<string name="follow_set_broadcast">Közvetítési lista</string>
<string name="follow_pack_broadcast">Közvetítési csomag</string>
<string name="follow_set_delete">Lista törlése</string>
<string name="follow_pack_delete">Csomag törlése</string>
</resources>
@@ -112,6 +112,7 @@
<string name="post">Verzenden</string>
<string name="save">Opslaan</string>
<string name="create">Maken</string>
<string name="rename">Hernoemen</string>
<string name="cancel">Annuleren</string>
<string name="failed_to_upload_the_image">Uploaden afbeelding mislukt</string>
<string name="relay_address">Relay adres</string>
@@ -124,6 +125,8 @@
<string name="public_chat_feed">Publieke chats</string>
<string name="global_feed">Globale feed</string>
<string name="search_feed">Zoeken</string>
<string name="search_and_add_a_user">Zoek en voeg gebruiker toe</string>
<string name="add_a_user">Gebruiker toevoegen</string>
<string name="add_a_relay">Voeg relay toe</string>
<string name="profile_name">Naam</string>
<string name="display_name">Naam</string>
@@ -344,6 +347,7 @@
<string name="report_dialog_post_report_btn">Rapporteer</string>
<string name="report_dialog_title">Blokkeer en rapporteer</string>
<string name="block_only">Blokkeren</string>
<string name="manual_zaps">Handmatige Zap Splits</string>
<string name="bookmarks">Bladwijzers</string>
<string name="drafts">Concepten</string>
<string name="private_bookmarks">Privé bladwijzers</string>
@@ -441,10 +445,64 @@
<string name="follow_list_selection">Volgerslijst</string>
<string name="follow_list_kind3follows">Iedereen</string>
<string name="follow_list_kind3follows_users_only">Volg alle gebruikers</string>
<string name="follow_list_kind3_follows_users_only">Standaard volglijst</string>
<string name="follow_list_kind3follows_proxy">Volgt via proxy</string>
<string name="follow_list_aroundme">In de buurt</string>
<string name="follow_list_global">Globaal</string>
<string name="follow_list_mute_list">Mute-lijst</string>
<string name="follow_sets">Volglijst</string>
<string name="follow_sets_explainer">Dit zijn volglijsten ontworpen voor eigen gebruik. Je kunt gebruikers privé of publiekelijk volgen</string>
<string name="labeled_bookmarks">Gelabelde bladwijzers</string>
<string name="general_bookmarks">Algemene bladwijzers</string>
<string name="follow_set_type_public">Openbaar</string>
<string name="follow_set_type_private">Privé</string>
<string name="follow_set_type_mixed">Gemengd</string>
<string name="follow_set_empty_feed_msg"> Het lijkt erop dat je nog geen volgsets hebt.
\nTik hieronder om te vernieuwen, of tik op de toevoegen knoppen om een nieuwe te maken.
</string>
<string name="follow_set_create_btn_label">Nieuw</string>
<string name="follow_set_add_author_from_note_action">Voeg auteur toe aan volglijst</string>
<string name="follow_set_profile_actions_menu_description">Gebruiker toevoegen of verwijderen uit lijsten, of maak een nieuwe lijst aan met deze gebruiker.</string>
<string name="follow_set_icon_description">Icoon voor volgset</string>
<string name="follow_set_public_presence_indicator">%1$s is een openbaar lid</string>
<string name="follow_set_private_presence_indicator">%1$s is een privélid</string>
<string name="follow_set_public_member_add_label">Voeg toe als openbaar lid</string>
<string name="follow_set_private_member_add_label">Als privélid toevoegen</string>
<string name="follow_set_public_members_header_label2">Openbare leden</string>
<string name="follow_set_private_members_header_label2">Privé leden</string>
<string name="follow_set_public_members_header_label">Openbare profielen</string>
<string name="follow_set_private_members_header_label">Privé profielen</string>
<string name="follow_set_single_member_label">lid</string>
<string name="follow_set_multiple_member_label">leden</string>
<string name="follow_set_empty_label">Geen leden</string>
<string name="follow_set_empty_label2">Leeg</string>
<string name="follow_set_absence_indicator">%1$s staat niet in deze lijst</string>
<string name="follow_set_absence_indicator2">%1$s is geen lid</string>
<string name="follow_set_man_dialog_title2">Jouw lijsten en %1$s</string>
<string name="follow_set_man_dialog_title">Jouw lijsten</string>
<string name="follow_set_empty_dialog_msg">Er zijn geen volglijsten gevonden, of je hebt geen volglijsten. Tik hieronder om te vernieuwen of gebruik het menu om er een aan te maken.</string>
<string name="follow_set_error_dialog_msg">Er was een probleem tijdens het ophalen: %1$s</string>
<string name="follow_set_creation_menu_title">Maak nieuwe lijst</string>
<string name="follow_set_creation_item_label">Nieuwe lijst met %1$s lidmaatschap</string>
<string name="follow_set_creation_item_description">Creëert een nieuwe volgset en voegt %1$s toe als lid van %2$s.</string>
<string name="follow_set_creation_dialog_title">Nieuwe volglijst</string>
<string name="follow_pack_creation_dialog_title">Nieuwe volglijst</string>
<string name="follow_set_copy_dialog_title">Kopieer/kloon volglijst</string>
<string name="follow_set_desc_modify_label">Beschrijving wijzigen</string>
<string name="follow_set_empty_desc_label">Deze lijst heeft geen beschrijving</string>
<string name="follow_set_current_desc_label">Huidige beschrijving:</string>
<string name="follow_set_copy_indicator_description">Stel een nieuwe naam/omschrijving in voor de gekloonde lijst hieronder.</string>
<string name="follow_set_creation_name_label">Lijstnaam</string>
<string name="follow_set_copy_name_label">Nieuwe lijstnaam</string>
<string name="follow_set_creation_desc_label">Lijstbeschrijving (optioneel)</string>
<string name="follow_set_copy_desc_label">Beschrijving nieuwe lijst</string>
<string name="follow_set_creation_action_btn_label">Lijst maken</string>
<string name="follow_set_copy_action_btn_label">Kopieer/kloon lijst</string>
<string name="follow_set_rename_btn_label">Lijst hernoemen</string>
<string name="follow_set_edit_list_metadata">Bewerk lijst</string>
<string name="follow_set_desc_modify_btn_label">Aanpassen</string>
<string name="follow_set_rename_dialog_indicator_first_part">Je hernoemt de naam van </string>
<string name="follow_set_rename_dialog_indicator_second_part"> om..</string>
<string name="connect_through_your_orbot_setup_short">Standaard poort is 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Verbind via Tor met Orbot
\n\n1. Installeer [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -580,6 +638,7 @@
<string name="followed_tags">Gevolgde tags</string>
<string name="relay_setup">Relays</string>
<string name="discover_follows">Volg pakketten</string>
<string name="discover_follows_explainer">Dit zijn lijsten van gebruikers die u aanbeveelt aan andere mensen. Alleen openbare gebruikers zijn toegestaan.</string>
<string name="discover_reads">Gelezen</string>
<string name="discover_content_v2">Feed algoritmes</string>
<string name="discover_marketplace">Marktplaats</string>
@@ -661,6 +720,17 @@
<string name="paste_from_clipboard">Plakken vanaf klembord</string>
<string name="invalid_nip47_uri_title">Invalide NIP-47 URI</string>
<string name="invalid_nip47_uri_description">De URI %1$s is geen geldige NIP-47 login-URI.</string>
<string name="connect_to_new_nwc_wallet">Verbinding maken met nieuwe NWC-wallet</string>
<string name="couldnt_find_nwc_wallets">Wallet niet gevonden</string>
<string name="couldnt_find_nwc_wallets_description">Amethyst kon geen wallet-apps vinden die Nostr Wallet Connect (NWC) ondersteunen.
\n\nWat je kan doen:
\n\t1. Verifieer NWC Support: Controleer of uw voorkeurswallet app compatibel is met Nostr Wallet Connect.
\n\t2. Maak een verbinding. Ga naar uw wallet-app en maak een nieuwe NWC verbinding aan.
\n\t3. Link naar Amethyst: Je wallet geeft je meestal drie opties:
\n\t\t3. Automatische link: Een knop om automatisch te openen en te verbinden met Amethyst. Als dat zo is, klik dan op die knop, open Amethist en druk op opslaan op het nieuwe scherm.
\n\t\t3. Handmatige link: Een URI voor verbinding, u kunt het plakpictogram op dit scherm kopiëren en klikken om het direct in te voeren.
\n\t\t3. QR-Code: De wallet maakt een QR-code die je kunt lezen op de QR-code knop op dit scherm.
</string>
<string name="language_description">Voor de interface van de app</string>
<string name="theme_description">Donker-, Licht- of Systeemthema</string>
<string name="automatically_load_images_gifs_description">Automatisch afbeeldingen en GIF\'s laden</string>
@@ -832,6 +902,8 @@
<string name="media_compression_quality_medium">Normaal</string>
<string name="media_compression_quality_high">Hoog</string>
<string name="media_compression_quality_uncompressed">Ongecomprimeerd</string>
<string name="video_codec_h265_label">H.265/HEVC Codec gebruiken</string>
<string name="video_codec_h265_description">Betere kwaliteit in kleinere bestandsformaten, maar niet alle apparaten ondersteunen H.265 playback.</string>
<string name="edit_draft">Concept bewerken</string>
<string name="login_with_qr_code">Inloggen met QR-Code</string>
<string name="route">Route</string>
@@ -890,6 +962,16 @@
<string name="search_relays_not_found_description">Het maken van een relay lijst speciaal ontworpen voor zoekopdracht en gebruikers-tagging zal deze resultaten verbeteren.</string>
<string name="search_relays_not_found_editing">Voeg tussen de 1 en 3 relays toe om te gebruiken bij het zoeken naar content of bij het taggen van gebruikers. Zorg ervoor dat uw gekozen relays implementatie van NIP-50</string>
<string name="search_relays_not_found_examples">Goede opties zijn:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="outbox_relays_title">Outbox relays</string>
<string name="outbox_relays_not_found">Stel uw openbare outbox relays in om te posten</string>
<string name="outbox_relays_not_found_description">Het maken van een relay-lijst speciaal ontworpen voor het ontvangen van jouw inhoud is cruciaal voor de Nostr ervaring en de enige manier waarop jouw volgers je kunnen vinden. </string>
<string name="outbox_relays_not_found_editing">Voer tussen de 1 en 3 relays in om je berichten te ontvangen. Zorg ervoor dat je geen betaling nodig hebt als je niet betaalt om in te voegen</string>
<string name="outbox_relays_not_found_examples">Goede opties zijn:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Inbox relays</string>
<string name="inbox_relays_not_found">Stel uw openbare inbox relays in om meldingen te ontvangen</string>
<string name="inbox_relays_not_found_description">Het maken van een relay-lijst speciaal ontworpen voor het ontvangen van meldingen is cruciaal voor uw Nostr ervaring. </string>
<string name="inbox_relays_not_found_editing">Voeg tussen de 1 en 3 relays toe die meldingen voor u ontvangen. Zorg ervoor dat ze geen betaling of vertrouwensweb van de afzender nodig hebben, anders ontvangt u alleen meldingen van betalers of langdurig gebruikers</string>
<string name="inbox_relays_not_found_examples">Goede opties zijn:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">DM uploaden</string>
<string name="relay_settings">Relay-instellingen</string>
<string name="public_home_section">Openbare thuis relays</string>
@@ -1015,6 +1097,9 @@
<string name="torrent_failure">Kon bestand niet openen</string>
<string name="torrent_no_apps">Geen torrent apps geïnstalleerd om het bestand te openen en te downloaden.</string>
<string name="torrent_no_info">Evenement heeft niet genoeg informatie om een magnet-link te maken</string>
<string name="my_lists_and_sets">Mijn lijsten/sets</string>
<string name="my_lists">Mijn lijsten</string>
<string name="people_list_label">Gebruikers</string>
<string name="select_list_to_filter">Selecteer een lijst om de feed te filteren</string>
<string name="temporary_account">Afmelden bij apparaatvergrendeling</string>
<string name="private_message">Privébericht</string>
@@ -1036,4 +1121,27 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Wilt u het recente crashrapport verzenden naar Amethyst in een DM? Er zal geen persoonlijke informatie gedeeld worden</string>
<string name="crashreport_found_send">Verzenden</string>
<string name="this_message_will_disappear_in_days">Dit bericht zal verdwijnen in %1$d dagen</string>
<string name="select_signer">Selecteer ondertekenaar</string>
<string name="in_the_list">Al in de lijst</string>
<string name="private_members">Privé leden</string>
<string name="public_members">Openbare leden</string>
<string name="private_members_count">Privéleden (%1$s)</string>
<string name="public_members_count">Openbare leden (%1$s)</string>
<string name="add_user_to_the_list">Gebruiker toevoegen aan de lijst</string>
<string name="remove_user_from_the_list">Gebruiker uit de lijst verwijderen</string>
<string name="follow_list_item_label">Volgpakket</string>
<string name="members">Leden</string>
<string name="people_list_title">Volglijst metadata</string>
<string name="people_list_explainer">Volglijsten metadata kunnen door iedereen op Nostr. Alleen uw privéleden zijn versleuteld.</string>
<string name="follow_pack_title">Volglijst metadata</string>
<string name="follow_pack_explainer">Volgpakket metadata kan door iedereen op Nostr worden gezien en is vaak op veel websites gepubliceerd als opstartuitrustingen voor nieuwe gebruikers.</string>
<string name="follow_pack_edit_list_metadata">Wijzig volglijst</string>
<string name="follow_pack_creation_name_label">Pakketnaam</string>
<string name="follow_pack_copy_name_label">Nieuwe pakketnaam</string>
<string name="follow_pack_creation_desc_label">Pakketbeschrijving</string>
<string name="follow_pack_copy_desc_label">Nieuwe pakketbeschrijving</string>
<string name="follow_set_broadcast">Uitzendlijst</string>
<string name="follow_pack_broadcast">Uitzendpakket</string>
<string name="follow_set_delete">Verwijder lijst</string>
<string name="follow_pack_delete">Verwijder pakket</string>
</resources>
+57 -34
View File
@@ -55,7 +55,7 @@
<string name="login_with_a_private_key_to_be_able_to_upload">Używasz klucza publicznego i kluczy publicznych tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc wgrać</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">Używasz klucza publicznego i kluczy publicznych tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc zapisać się na udział w wydarzeniu</string>
<string name="unauthorized_exception">Nieautoryzowane odszyfrowywanie</string>
<string name="unauthorized_exception_description">Sygnatariusz nie autoryzował deszyfrowania wymaganego do wykonania tej operacji. Aktywuj deszyfrowanie NIP-44 w aplikacji podpisującej i spróbuj ponownie</string>
<string name="unauthorized_exception_description">Sygnatariusz nie autoryzował deszyfrowania wymaganego do wykonania tej operacji. Aktywuj deszyfrowanie NIP-44 w aplikacji logującej i spróbuj ponownie</string>
<string name="signer_not_found_exception">Nie odnaleziono sygnatariusza</string>
<string name="signer_not_found_exception_description">Czy aplikacja sygnatariusza została odinstalowana? Sprawdź, czy aplikacja sygnatariusza jest zainstalowana i czy ma to konto. Wyloguj się i zaloguj ponownie, jeśli aplikacja sygnatariusza uległa zmianie.</string>
<string name="zaps">Zapy</string>
@@ -68,9 +68,9 @@
<string name="quote">Zacytuj</string>
<string name="fork">Sklonuj</string>
<string name="propose_an_edit">Zaproponuj zmianę</string>
<string name="new_amount_in_sats">Nowa kwota w Satach</string>
<string name="new_amount_in_sats">Nowa kwota w Satsach</string>
<string name="add">Dodaj</string>
<string name="replying_to">"odpowiadając do"</string>
<string name="replying_to">"odpowiadając do "</string>
<string name="and">" i "</string>
<string name="in_channel">"na kanale "</string>
<string name="profile_banner">Baner profilu</string>
@@ -80,7 +80,7 @@
<string name="followers">" Obserwujące"</string>
<string name="number_following">"%1$s Obserwowanych"</string>
<string name="number_followers">"%1$s Obserwujących"</string>
<string name="profile">Twój Profil</string>
<string name="profile">Mój Profil</string>
<string name="security_filters">Filtry bezpieczeństwa</string>
<string name="log_out">Wyloguj się</string>
<string name="show_more">Pokaż Więcej</string>
@@ -344,6 +344,7 @@
<string name="report_dialog_post_report_btn">Wyślij zgłoszenie</string>
<string name="report_dialog_title">Zablokuj i zgłoś</string>
<string name="block_only">Zablokuj</string>
<string name="manual_zaps">Ręczny podział zapów</string>
<string name="bookmarks">Zakładki</string>
<string name="drafts">Projekty</string>
<string name="private_bookmarks">Prywatne Zakładki</string>
@@ -440,58 +441,62 @@
<string name="no">Nie</string>
<string name="follow_list_selection">Lista obserwowanych</string>
<string name="follow_list_kind3follows">Obserwowane</string>
<string name="follow_list_kind3follows_users_only">Wszyscy obserwujący</string>
<string name="follow_list_kind3follows_users_only">Obserwujące</string>
<string name="follow_list_kind3_follows_users_only">Domyślna lista obserwowanych</string>
<string name="follow_list_kind3follows_proxy">Obserwuje przez proxy</string>
<string name="follow_list_aroundme">W pobliżu</string>
<string name="follow_list_global">Wszystkie</string>
<string name="follow_list_mute_list">Zablokowane</string>
<string name="follow_sets">Zbiory obserwowanych</string>
<string name="follow_sets">Listy obserwowanych</string>
<string name="follow_sets_explainer">Są to listy obserwowanych przeznaczone do prywatnego użytku. Możesz obserwować użytkowników dyskretnie lub otwarcie</string>
<string name="labeled_bookmarks">Oznaczone zakładki</string>
<string name="general_bookmarks">Ogólne zakładki</string>
<string name="follow_set_type_public">Publiczna</string>
<string name="follow_set_type_private">Prywatna</string>
<string name="follow_set_type_mixed">Mieszana</string>
<string name="follow_set_empty_feed_msg"> Wygląda na to, że nie masz jeszcze żadnych zbiorów obserwowanych.
\nDotknij poniżej, aby odświeżyć, lub naciśnij przycisk Dodaj, aby utworzyć nowy.
<string name="follow_set_empty_feed_msg">Wygląda na to, że nie masz jeszcze żadnej listy obserwowanych.
\nDotknij poniżej, aby odświeżyć, lub naciśnij przycisk Dodaj, aby utworzyć listę.
</string>
<string name="follow_set_create_btn_label">Nowy</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do zbioru obserwowanych</string>
<string name="follow_set_create_btn_label">Nowa</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_icon_description">Ikona dla listy %1$s</string>
<string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string>
<string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string>
<string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string>
<string name="follow_set_private_member_add_label">Dodaj jako uczestnika prywatnego</string>
<string name="follow_set_public_members_header_label2">Uczestnicy publiczni</string>
<string name="follow_set_private_members_header_label2">Prywatni uczestnicy</string>
<string name="follow_set_public_members_header_label2">Publiczne</string>
<string name="follow_set_private_members_header_label2">Prywatne</string>
<string name="follow_set_public_members_header_label">Profile publiczne</string>
<string name="follow_set_private_members_header_label">Profile prywatne</string>
<string name="follow_set_single_member_label">uczestnik</string>
<string name="follow_set_multiple_member_label">uczestnicy</string>
<string name="follow_set_multiple_member_label">członkowie</string>
<string name="follow_set_empty_label">Brak uczestników</string>
<string name="follow_set_empty_label2">Pusty</string>
<string name="follow_set_empty_label2">Pusto</string>
<string name="follow_set_absence_indicator">%1$s nie jest na liście</string>
<string name="follow_set_absence_indicator2">%1$s nie jest uczestnikiem</string>
<string name="follow_set_man_dialog_title2">Twoje listy oraz %1$s</string>
<string name="follow_set_man_dialog_title">Twój zbiór obserwowanych</string>
<string name="follow_set_empty_dialog_msg">Nie znaleziono zbiorów obserwowanych lub nie masz żadnych zbiorów obserwowanych. Dotknij poniżej, aby odświeżyć lub użyj menu, aby go utworzyć.</string>
<string name="follow_set_man_dialog_title2">%1$s na Twoich listach</string>
<string name="follow_set_man_dialog_title">Twoja lista obserwowanych</string>
<string name="follow_set_empty_dialog_msg">Nie znaleziono list obserwowanych lub nie masz żadnych list obserwowanych. Naciśnij poniżej, aby odświeżyć lub użyj menu, aby utworzyć nową.</string>
<string name="follow_set_error_dialog_msg">Podczas pobierania wystąpił błąd: %1$s</string>
<string name="follow_set_creation_menu_title">Utwórz nową listę</string>
<string name="follow_set_creation_item_label">\"Utwórz nową listę %1$s z użytkownikiem</string>
<string name="follow_set_creation_item_description">Tworzy zbiór obserwowanych %1$s i dodaje do niego %2$s.</string>
<string name="follow_set_creation_dialog_title">Nowa lista %1$s</string>
<string name="follow_set_copy_dialog_title">Kopiuj/Klonuj zbiór obserwowanych</string>
<string name="follow_set_creation_item_label">\"Utwórz nową listę %1$s z członkiem</string>
<string name="follow_set_creation_item_description">Tworzy zestaw obserwowanych %1$s i dodaje do niego %2$s.</string>
<string name="follow_set_creation_dialog_title">Nowa lista</string>
<string name="follow_pack_creation_dialog_title">Nowa Kategoria</string>
<string name="follow_set_copy_dialog_title">Kopiuj/Sklonuj listę obserwowanych</string>
<string name="follow_set_desc_modify_label">Modyfikuj opis</string>
<string name="follow_set_empty_desc_label">Ta lista nie ma opisu</string>
<string name="follow_set_current_desc_label">Aktualny opis:</string>
<string name="follow_set_copy_indicator_description">Możesz wybrać własną nazwę/opis dla sklonowanego zbioru poniżej.</string>
<string name="follow_set_creation_name_label">Nazwa zbioru</string>
<string name="follow_set_copy_indicator_description">Ustaw nową nazwę/opis dla sklonowanej listy poniżej.</string>
<string name="follow_set_creation_name_label">Nazwa listy</string>
<string name="follow_set_copy_name_label">(Nazwa Oryginalna)</string>
<string name="follow_set_creation_desc_label">Opis zbioru (opcjonalnie)</string>
<string name="follow_set_copy_desc_label">(Opis oryginalnego zbioru)</string>
<string name="follow_set_creation_action_btn_label">Utwórz zbiór</string>
<string name="follow_set_copy_action_btn_label">Kopiuj/Klonuj zbiór</string>
<string name="follow_set_rename_btn_label">Zmień nazwę zbioru</string>
<string name="follow_set_creation_desc_label">Opis listy (opcjonalnie)</string>
<string name="follow_set_copy_desc_label">Opis nowej listy</string>
<string name="follow_set_creation_action_btn_label">Utwórz listę</string>
<string name="follow_set_copy_action_btn_label">Kopiuj/Sklonuj listę</string>
<string name="follow_set_rename_btn_label">Zmień nazwę listy</string>
<string name="follow_set_edit_list_metadata">Edytuj listę</string>
<string name="follow_set_desc_modify_btn_label">Modyfikuj</string>
<string name="follow_set_rename_dialog_indicator_first_part">Zmieniasz nazwę z </string>
<string name="follow_set_rename_dialog_indicator_second_part"> do..</string>
@@ -630,6 +635,7 @@
<string name="followed_tags">Obserwowane tagi</string>
<string name="relay_setup">Transmitery</string>
<string name="discover_follows">Godne uwagi</string>
<string name="discover_follows_explainer">Są to listy profili, które polecasz innym użytkownikom. Dozwolone są wyłącznie profile publiczne.</string>
<string name="discover_reads">Popularne</string>
<string name="discover_content_v2">Wybrane przez algorytm</string>
<string name="discover_marketplace">Market</string>
@@ -777,11 +783,11 @@
<string name="lightning_wallets_not_found2">Nie znaleziono portfeli Lightning</string>
<string name="paid">Zapłacone</string>
<string name="wallet_number">Portfel %1$s</string>
<string name="error_opening_external_signer">Błąd podczas otwierania aplikacji podpisującej</string>
<string name="error_opening_external_signer_description">Nie można odnaleźć aplikacji podpisującej. Sprawdź, czy aplikacja nie została odinstalowana</string>
<string name="error_opening_external_signer">Błąd podczas otwierania aplikacji logującej</string>
<string name="error_opening_external_signer_description">Nie można odnaleźć aplikacji logującej. Sprawdź, czy aplikacja nie została odinstalowana</string>
<string name="sign_request_rejected2">Prośba o zalogowanie została odrzucona</string>
<string name="sign_request_rejected">Odrzucono aplikację podpisującego</string>
<string name="sign_request_rejected_description">Upewnij się, że aplikacja podpisującego autoryzuje tę transakcję</string>
<string name="sign_request_rejected">Prośba o zalogowanie odrzucona</string>
<string name="sign_request_rejected_description">Upewnij się, że aplikacja logującego autoryzuje tę operację</string>
<string name="no_wallet_found_with_error">Nie znaleziono portfeli do zapłacenia faktury z Lightning (Error: %1$s). Proszę zainstalować Lightning wallet, aby używać zapów</string>
<string name="no_wallet_found">Nie znaleziono portfeli do zapłacenia faktury z Lightning. Proszę zainstalować Lightning wallet, aby używać zapów</string>
<string name="hidden_words">Ukryte słowa</string>
@@ -1088,10 +1094,10 @@
<string name="torrent_failure">Nie udało się otworzyć pliku</string>
<string name="torrent_no_apps">Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku.</string>
<string name="torrent_no_info">Zdarzenie nie ma wystarczającej ilości informacji, aby zbudować link magnetyczny</string>
<string name="my_lists_and_sets">Moje Listy/Zbiory</string>
<string name="my_lists_and_sets">Moje Listy/Zestawy</string>
<string name="my_lists">Moje listy</string>
<string name="people_list_label">Użytkownicy</string>
<string name="select_list_to_filter">Wybierz listę, aby filtrować aktualności</string>
<string name="select_list_to_filter">Wybierz listę profili, aby filtrować aktualności</string>
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
<string name="private_message">Wiadomość prywatna</string>
<string name="public_message">Publiczna wiadomość</string>
@@ -1116,6 +1122,23 @@
<string name="in_the_list">Już jest na liście</string>
<string name="private_members">Prywatni uczestnicy</string>
<string name="public_members">Uczestnicy publiczni</string>
<string name="private_members_count">Uczestnicy prywatni (%1$s)</string>
<string name="public_members_count">Uczestnicy publiczni (%1$s)</string>
<string name="add_user_to_the_list">Dodaj użytkownika do listy</string>
<string name="remove_user_from_the_list">Usuń użytkownika z listy</string>
<string name="follow_list_item_label">Kategoria profilów</string>
<string name="members">Uczestnicy</string>
<string name="people_list_title">Metadane listy profili</string>
<string name="people_list_explainer">Metadane listy obserwowanych są widoczne dla wszystkich użytkowników w sieci Nostr. Tylko Twoi uczestnicy prywatni są zaszyfrowani.</string>
<string name="follow_pack_title">Metadane kategorii profilów</string>
<string name="follow_pack_explainer">Metadane kategorii profilów są widoczne dla wszystkich użytkowników serwisu Nostr i często publikowane na wielu stronach internetowych jako zestawy startowe dla nowych użytkowników.</string>
<string name="follow_pack_edit_list_metadata">Edytuj kategorię profili</string>
<string name="follow_pack_creation_name_label">Nazwa kategorii</string>
<string name="follow_pack_copy_name_label">Nowa nazwa kategorii</string>
<string name="follow_pack_creation_desc_label">Opis kategorii</string>
<string name="follow_pack_copy_desc_label">Opis nowej kategorii</string>
<string name="follow_set_broadcast">Upowszechnij listę</string>
<string name="follow_pack_broadcast">Upowszechnij kategorię</string>
<string name="follow_set_delete">Usuń listę</string>
<string name="follow_pack_delete">Usuń kategorię</string>
</resources>
@@ -345,6 +345,7 @@
<string name="report_dialog_post_report_btn">Enviar Denuncia</string>
<string name="report_dialog_title">Bloquear e Denunciar</string>
<string name="block_only">Bloquear</string>
<string name="manual_zaps">Divisão manual de Zap</string>
<string name="bookmarks">Itens Salvos</string>
<string name="drafts">Rascunhos</string>
<string name="private_bookmarks">Itens Salvos Privados</string>
@@ -448,6 +449,7 @@
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista Silenciada</string>
<string name="follow_sets">Listas de seguidores</string>
<string name="follow_sets_explainer">Essas são listas de seguidores criadas para seu próprio uso. Você pode seguir usuários de forma privada ou pública.</string>
<string name="labeled_bookmarks">Favoritos com etiqueta</string>
<string name="general_bookmarks">Favoritos gerais</string>
<string name="follow_set_type_public">Público</string>
@@ -480,6 +482,7 @@
<string name="follow_set_creation_item_label">Criar nova lista %1$s com usuário</string>
<string name="follow_set_creation_item_description">Cria um conjunto de seguimento %1$s e adiciona %2$s a ele.</string>
<string name="follow_set_creation_dialog_title">Nova lista de seguidores</string>
<string name="follow_pack_creation_dialog_title">Novo pacote de recomendações</string>
<string name="follow_set_copy_dialog_title">Copiar/Clonar lista de seguidores</string>
<string name="follow_set_desc_modify_label">Modificar descrição</string>
<string name="follow_set_empty_desc_label">Esta lista não tem descrição</string>
@@ -492,6 +495,7 @@
<string name="follow_set_creation_action_btn_label">Criar lista</string>
<string name="follow_set_copy_action_btn_label">Copiar/Clonar lista</string>
<string name="follow_set_rename_btn_label">Renomear lista</string>
<string name="follow_set_edit_list_metadata">Editar lista</string>
<string name="follow_set_desc_modify_btn_label">Modificar</string>
<string name="follow_set_rename_dialog_indicator_first_part">Você está renomeando de </string>
<string name="follow_set_rename_dialog_indicator_second_part"> para..</string>
@@ -630,6 +634,7 @@
<string name="followed_tags">Tags Seguidas</string>
<string name="relay_setup">Relés</string>
<string name="discover_follows">Pacotes Seguir</string>
<string name="discover_follows_explainer">Essas são listas de usuários que você recomenda para outras pessoas. Somente usuários públicos são permitidos.</string>
<string name="discover_reads">Leituras</string>
<string name="discover_content_v2">Algoritmos de feed</string>
<string name="discover_marketplace">Mercado</string>
@@ -1121,4 +1126,17 @@
<string name="remove_user_from_the_list">Remover usuário da lista</string>
<string name="follow_list_item_label">Pacote Seguir</string>
<string name="members">Membros</string>
<string name="people_list_title">Metadados da lista de seguidores</string>
<string name="people_list_explainer">Os metadados das listas de seguidores podem ser vistos por qualquer pessoa no Nostr. Apenas os membros privados são criptografados.</string>
<string name="follow_pack_title">Metadados do pacote de recomendações</string>
<string name="follow_pack_explainer">Os metadados dos pacotes de recomendações podem ser vistos por qualquer pessoa no Nostr e são frequentemente publicados em sites como kits iniciais para novos usuários.</string>
<string name="follow_pack_edit_list_metadata">Editar pacote de recomendações</string>
<string name="follow_pack_creation_name_label">Nome do pacote</string>
<string name="follow_pack_copy_name_label">Novo nome do pacote</string>
<string name="follow_pack_creation_desc_label">Descrição do pacote</string>
<string name="follow_pack_copy_desc_label">Nova descrição do pacote</string>
<string name="follow_set_broadcast">Transmitir lista</string>
<string name="follow_pack_broadcast">Transmitir pacote de recomendações</string>
<string name="follow_set_delete">Excluir lista</string>
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
</resources>
+32 -14
View File
@@ -21,13 +21,13 @@
<string name="illegal_behavior">Nedovoljeno obnašanje</string>
<string name="other">Drugo</string>
<string name="harassment">Nadlegovanje</string>
<string name="unknown">Neznano</string>
<string name="unknown">Nepoznano</string>
<string name="relay_icon">Ikona releja</string>
<string name="unknown_author">Avtor neznan</string>
<string name="copy_text">Kopiraj tekst</string>
<string name="copy_user_pubkey">Kopiraj ID avtorja</string>
<string name="copy_note_id">Kopiraj ID zapiska</string>
<string name="broadcast">Oddajanje</string>
<string name="broadcast">Razpošlji</string>
<string name="timestamp_it">Uporabi časovni žig</string>
<string name="timestamp_pending">Časovni žig: Potrditve v obdelavi</string>
<string name="timestamp_pending_short">OTS: V obdelavi</string>
@@ -89,7 +89,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="error_parsing_error_message">Napaka pri razčlembi sporočila o napaki</string>
<string name="following">" Sledi"</string>
<string name="followers">" Sledilcev"</string>
<string name="number_following">"%1$s Sledi"</string>
<string name="number_following">"%1$s Sledim"</string>
<string name="number_followers">"%1$s Sledilcev"</string>
<string name="profile">Profil</string>
<string name="security_filters">Varnostni filtri</string>
@@ -102,11 +102,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="thank_you_so_much">Najlepša hvala!</string>
<string name="amount_in_sats">Vsota v Sat</string>
<string name="send_sats">Pošlji Sat</string>
<string name="secret_emoji_maker">Orodje za emodžije s skrivnostmi</string>
<string name="secret_emoji_maker_explainer">Izberi emoi za pošiljanje s skritim sporočilom</string>
<string name="secret_note_to_receiver">Sprejem skrivnosti</string>
<string name="secret_note_to_receiver_placeholder">Moja skrita sporočila</string>
<string name="secret_visible_text">Vidni prefiksi</string>
<string name="secret_emoji_maker">Orodje za emoji s skrivnostmi</string>
<string name="secret_emoji_maker_explainer">Izberi emoji za pošiljanje s skritim sporočilom</string>
<string name="secret_note_to_receiver">Skrivno sporočilo</string>
<string name="secret_note_to_receiver_placeholder">Moje skrito sporočilo</string>
<string name="secret_visible_text">Vidna predpona</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Dodaj v zapisek</string>
<string name="error_parsing_preview_for">"Napaka pri razčlembi predogleda za %1$s : %2$s"</string>
@@ -173,11 +173,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiraj ID kanala (zapisek) v odložišče</string>
<string name="edits_the_channel_metadata">Uredi metapodatke kanala</string>
<string name="join">Pridruži se</string>
<string name="known">Znano</string>
<string name="known">Poznani</string>
<string name="new_requests">Nova prošnja</string>
<string name="blocked_users">Blokirani uporabniki</string>
<string name="new_threads">Nove objave</string>
<string name="conversations">Pogovori</string>
<string name="conversations">Razprave</string>
<string name="feed">Vsebine</string>
<string name="mod_queue">Moderatorska vrsta</string>
<string name="notes">Zapiski</string>
@@ -185,8 +185,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="mutual">Vaše</string>
<string name="gallery">Galerija</string>
<string name="follows">"sledilcev"</string>
<string name="reports">"Reportaže"</string>
<string name="number_reports">"%1$s Prijave"</string>
<string name="reports">"Prijave"</string>
<string name="number_reports">"%1$s Prijav"</string>
<string name="more_options">Več možnosti</string>
<string name="relays">" Releji"</string>
<string name="website">Spletna stran</string>
@@ -199,7 +199,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="copy_public_key_npub_to_the_clipboard">Kopiraj javni ključ (NPub) v odložišče</string>
<string name="send_a_direct_message">Pošlji direktno sporočilo</string>
<string name="edits_the_user_s_metadata">Uredi uporabnikove metapodatke</string>
<string name="follow">Sledim</string>
<string name="follow">Že sledim</string>
<string name="follow_back">Sledi nazaj</string>
<string name="unblock">Deblokiraj</string>
<string name="copy_user_id">Kopiraj uporabnikov ID</string>
@@ -358,6 +358,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="report_dialog_post_report_btn">Objavi prijavo</string>
<string name="report_dialog_title">Blokiraj in prijavi</string>
<string name="block_only">Blokiraj</string>
<string name="manual_zaps">Ročno razdeli zape</string>
<string name="bookmarks">Zaznamki</string>
<string name="drafts">Osnutki</string>
<string name="private_bookmarks">Privatni zaznamki</string>
@@ -452,7 +453,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="do_you_really_want_to_disable_tor_text">Vaši podatki bodo takoj preneseni v običajno omrežje</string>
<string name="yes">Da</string>
<string name="no">Ne</string>
<string name="follow_list_selection">Spisek komu sledimo</string>
<string name="follow_list_selection">Seznam sledenih</string>
<string name="follow_list_kind3follows">Vse, čemur sledimo</string>
<string name="follow_list_kind3follows_users_only">Vsi sledeni sledenih</string>
<string name="follow_list_kind3_follows_users_only">Privzeti seznam sledenih</string>
@@ -461,6 +462,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_list_global">Globalno</string>
<string name="follow_list_mute_list">Spisek utišanih</string>
<string name="follow_sets">Seznam sledenih</string>
<string name="follow_sets_explainer">To so seznami sledenih, namenjeni za vašo lastno uporabo. Uporabnike lahko sledite javno ali zasebno</string>
<string name="labeled_bookmarks">Označeni zaznamki</string>
<string name="general_bookmarks">Splošni zaznamki</string>
<string name="follow_set_type_public">Javno</string>
@@ -495,6 +497,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_set_creation_item_label">Nov seznam s %1$s člani</string>
<string name="follow_set_creation_item_description">Ustvari nov set sledenih, ter doda %1$s kot %2$s člana.</string>
<string name="follow_set_creation_dialog_title">Nov seznam sledenih</string>
<string name="follow_pack_creation_dialog_title">Nov paket sledenih</string>
<string name="follow_set_copy_dialog_title">Kopiraj/Kloniraj seznam sledenih</string>
<string name="follow_set_desc_modify_label">Spremeni opis</string>
<string name="follow_set_empty_desc_label">Ta seznam nima opisa</string>
@@ -507,6 +510,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_set_creation_action_btn_label">Ustvari seznam</string>
<string name="follow_set_copy_action_btn_label">Kopiraj/kloniraj seznam</string>
<string name="follow_set_rename_btn_label">Preimenuj seznam</string>
<string name="follow_set_edit_list_metadata">Uredi seznam</string>
<string name="follow_set_desc_modify_btn_label">Uredi</string>
<string name="follow_set_rename_dialog_indicator_first_part">Preimenuješ iz </string>
<string name="follow_set_rename_dialog_indicator_second_part"> v..</string>
@@ -645,6 +649,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="followed_tags">Spremljana vsebina</string>
<string name="relay_setup">Releji</string>
<string name="discover_follows">Paketi sledenih</string>
<string name="discover_follows_explainer">To so seznami uporabnikov, ki jih priporočate drugim. Dovoljeni so le javni uporabniki.</string>
<string name="discover_reads">Branje</string>
<string name="discover_content_v2">Algoritmi vsebin</string>
<string name="discover_marketplace">Tržnica</string>
@@ -1138,4 +1143,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="remove_user_from_the_list">Odstrani uporabnika iz seznama</string>
<string name="follow_list_item_label">Paket sledenih</string>
<string name="members">Člani</string>
<string name="people_list_title">Sledi metapodatkom seznama</string>
<string name="people_list_explainer">Sledenje metapodatkom seznama je lahko vidno vsem na Nostru. Le tvoji zasebni člani so šifrirani.</string>
<string name="follow_pack_title">Metapodatki paketa sledenih</string>
<string name="follow_pack_explainer">Metapodatki paketa sledenih so lahko vidno vsem na Nostru in redno objavljeni na ostalih spletnih straneh, kot začetni prikaz za nove uporabnike</string>
<string name="follow_pack_edit_list_metadata">Uredi paket sledenih</string>
<string name="follow_pack_creation_name_label">Ime paketa</string>
<string name="follow_pack_copy_name_label">Novo ime paketa</string>
<string name="follow_pack_creation_desc_label">Opis paketa</string>
<string name="follow_pack_copy_desc_label">Nov opis paketa</string>
<string name="follow_set_broadcast">Oddajaj seznam</string>
<string name="follow_pack_broadcast">Oddajaj paket</string>
<string name="follow_set_delete">Izbriši seznam</string>
<string name="follow_pack_delete">Izbriši paket</string>
</resources>
@@ -345,6 +345,7 @@
<string name="report_dialog_post_report_btn">Skicka rapport</string>
<string name="report_dialog_title">Blockera och rapportera</string>
<string name="block_only">Blockera</string>
<string name="manual_zaps">Manuell Zap delning</string>
<string name="bookmarks">Bokmärken</string>
<string name="drafts">Utkast</string>
<string name="private_bookmarks">Privata Bokmärken</string>
@@ -448,6 +449,7 @@
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Tyst listan</string>
<string name="follow_sets">Följelistor</string>
<string name="follow_sets_explainer">Detta är följlistor avsedda för ditt eget bruk. Du kan följa användare privat eller offentligt.</string>
<string name="labeled_bookmarks">Märkta bokmärken</string>
<string name="general_bookmarks">Allmänna bokmärken</string>
<string name="follow_set_type_public">Offentlig</string>
@@ -480,6 +482,7 @@
<string name="follow_set_creation_item_label">Skapa ny %1$s-lista med användare</string>
<string name="follow_set_creation_item_description">Skapar ett %1$s-följ-set och lägger till %2$s i det.</string>
<string name="follow_set_creation_dialog_title">Ny följelista</string>
<string name="follow_pack_creation_dialog_title">Nytt rekommendationspaket</string>
<string name="follow_set_copy_dialog_title">Kopiera/Klona följelista</string>
<string name="follow_set_desc_modify_label">Ändra beskrivning</string>
<string name="follow_set_empty_desc_label">Den här listan har ingen beskrivning</string>
@@ -492,6 +495,7 @@
<string name="follow_set_creation_action_btn_label">Skapa lista</string>
<string name="follow_set_copy_action_btn_label">Kopiera/Klona lista</string>
<string name="follow_set_rename_btn_label">Byt namn på lista</string>
<string name="follow_set_edit_list_metadata">Redigera lista</string>
<string name="follow_set_desc_modify_btn_label">Ändra</string>
<string name="follow_set_rename_dialog_indicator_first_part">Du byter namn från </string>
<string name="follow_set_rename_dialog_indicator_second_part"> till..</string>
@@ -629,6 +633,7 @@
<string name="followed_tags">Följda taggar</string>
<string name="relay_setup">Reläer</string>
<string name="discover_follows">Följpaket</string>
<string name="discover_follows_explainer">Detta är listor med användare som du rekommenderar till andra. Endast offentliga användare är tillåtna.</string>
<string name="discover_reads">Läsningar</string>
<string name="discover_content_v2">Feed algoritmer</string>
<string name="discover_marketplace">Marknadsplats</string>
@@ -1120,4 +1125,17 @@
<string name="remove_user_from_the_list">Ta bort användare från listan</string>
<string name="follow_list_item_label">Följpaket</string>
<string name="members">Medlemmar</string>
<string name="people_list_title">Metadata för följlista</string>
<string name="people_list_explainer">Metadata för följlistor kan ses av alla på Nostr. Endast privata medlemmar är krypterade.</string>
<string name="follow_pack_title">Metadata för rekommendationspaket</string>
<string name="follow_pack_explainer">Metadata för rekommendationspaket kan ses av alla på Nostr och publiceras ofta på webbplatser som startpaket för nya användare.</string>
<string name="follow_pack_edit_list_metadata">Redigera rekommendationspaket</string>
<string name="follow_pack_creation_name_label">Paketnamn</string>
<string name="follow_pack_copy_name_label">Nytt paketnamn</string>
<string name="follow_pack_creation_desc_label">Paketbeskrivning</string>
<string name="follow_pack_copy_desc_label">Ny paketbeskrivning</string>
<string name="follow_set_broadcast">Sänd lista</string>
<string name="follow_pack_broadcast">Sänd rekommendationspaket</string>
<string name="follow_set_delete">Ta bort lista</string>
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
</resources>
@@ -444,11 +444,13 @@
<string name="follow_list_selection">关注列表</string>
<string name="follow_list_kind3follows">所有关注</string>
<string name="follow_list_kind3follows_users_only">所有用户关注</string>
<string name="follow_list_kind3_follows_users_only">默认关注列表</string>
<string name="follow_list_kind3follows_proxy">通过代理关注</string>
<string name="follow_list_aroundme">周围的人</string>
<string name="follow_list_global">全球</string>
<string name="follow_list_mute_list">静音列表</string>
<string name="follow_sets">关注集</string>
<string name="follow_sets_explainer">这些是为您自己使用设计的关注列表。您可以私下或公开关注用户</string>
<string name="labeled_bookmarks">有标签的书签</string>
<string name="general_bookmarks">常规书签</string>
<string name="follow_set_type_public">公开</string>
@@ -483,6 +485,7 @@
<string name="follow_set_creation_item_label">创建用户新的 %1$s 列表</string>
<string name="follow_set_creation_item_description">创建 %1$s 关注集,并添加 %2$s 到其中。</string>
<string name="follow_set_creation_dialog_title">新的 %1$s 列表</string>
<string name="follow_pack_creation_dialog_title">新的关注包</string>
<string name="follow_set_copy_dialog_title">复制/克隆关注集</string>
<string name="follow_set_desc_modify_label">修改描述</string>
<string name="follow_set_empty_desc_label">此列表没有描述</string>
@@ -494,7 +497,8 @@
<string name="follow_set_copy_desc_label">(原始集合描述)</string>
<string name="follow_set_creation_action_btn_label">新建集</string>
<string name="follow_set_copy_action_btn_label">复制/克隆集</string>
<string name="follow_set_rename_btn_label">重命名</string>
<string name="follow_set_rename_btn_label">重命名列表</string>
<string name="follow_set_edit_list_metadata">编辑列表</string>
<string name="follow_set_desc_modify_btn_label">修改</string>
<string name="follow_set_rename_dialog_indicator_first_part">正将集合名称从 </string>
<string name="follow_set_rename_dialog_indicator_second_part"> 改为</string>
@@ -633,6 +637,7 @@
<string name="followed_tags">已关注的标签</string>
<string name="relay_setup">中继器</string>
<string name="discover_follows">关注包</string>
<string name="discover_follows_explainer">这些是您推荐给其他人的用户列表。只允许公开用户。</string>
<string name="discover_reads">次浏览</string>
<string name="discover_content_v2">动态源算法</string>
<string name="discover_marketplace">市场</string>
@@ -1119,5 +1124,23 @@
<string name="in_the_list">已经在列表中</string>
<string name="private_members">私密成员</string>
<string name="public_members">公开成员</string>
<string name="private_members_count">私密成员(%1$s</string>
<string name="public_members_count">公开成员 (%1$s)</string>
<string name="add_user_to_the_list">添加用户到列表</string>
<string name="remove_user_from_the_list">从列表中删除用户</string>
<string name="follow_list_item_label">关注包</string>
<string name="members">成员</string>
<string name="people_list_title">关注列表元数据</string>
<string name="people_list_explainer">关注列表元数据可以被Nostr上的任何人看到。只有您的私人成员是加密的。</string>
<string name="follow_pack_title">关注包元数据</string>
<string name="follow_pack_explainer">关注包元数据可以被Nostr上的任何人看见,并且常作为新用户启动包发布在许多网站上。</string>
<string name="follow_pack_edit_list_metadata">编辑关注包</string>
<string name="follow_pack_creation_name_label">包名</string>
<string name="follow_pack_copy_name_label">新的包名称</string>
<string name="follow_pack_creation_desc_label">包说明</string>
<string name="follow_pack_copy_desc_label">新的包说明</string>
<string name="follow_set_broadcast">广播列表</string>
<string name="follow_pack_broadcast">广播包</string>
<string name="follow_set_delete">删除列表</string>
<string name="follow_pack_delete">删除包</string>
</resources>
+1
View File
@@ -378,6 +378,7 @@
<string name="report_dialog_title">Block and Report</string>
<string name="block_only">Block</string>
<string name="manual_zaps">Manual Zap Splits</string>
<string name="bookmarks">Bookmarks</string>
<string name="drafts">Drafts</string>
<string name="private_bookmarks">Private Bookmarks</string>
+6 -6
View File
@@ -1,7 +1,7 @@
[versions]
accompanistAdaptive = "0.37.3"
activityCompose = "1.11.0"
agp = "8.13.0"
agp = "8.13.1"
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"
@@ -13,7 +13,7 @@ benchmark = "1.4.1"
benchmarkJunit4 = "1.4.1"
biometricKtx = "1.2.0-alpha05"
coil = "3.3.0"
composeBom = "2025.10.01"
composeBom = "2025.11.00"
coreKtx = "1.17.0"
datastore = "1.1.7"
espressoCore = "3.7.0"
@@ -38,7 +38,7 @@ markdown = "f92ef49c9d"
media3 = "1.8.0"
mockk = "1.14.6"
kotlinx-coroutines-test = "1.10.2"
navigationCompose = "2.9.5"
navigationCompose = "2.9.6"
okhttp = "5.3.0"
runner = "1.7.0"
rfc3986 = "0.1.2"
@@ -49,10 +49,10 @@ torAndroid = "0.4.8.19"
translate = "17.0.3"
unifiedpush = "3.0.10"
urlDetector = "0.1.23"
vico-charts = "2.3.0"
vico-charts = "2.3.4"
zelory = "3.0.1"
zoomable = "2.8.2"
zxing = "3.5.3"
zoomable = "2.9.0"
zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.0"
androidxCamera = "1.5.1"
+1 -1
View File
@@ -255,7 +255,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.03.0"
version = "1.04.2"
)
// Configure publishing to Maven Central
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip01Core.relay.client
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
@@ -32,7 +31,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
interface INostrClient {
fun relayStatusFlow(): StateFlow<RelayPool.RelayPoolStatus>
fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
fun connect()
@@ -80,7 +81,9 @@ interface INostrClient {
}
object EmptyNostrClient : INostrClient {
override fun relayStatusFlow() = MutableStateFlow(RelayPool.RelayPoolStatus())
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun connect() { }
@@ -320,5 +320,7 @@ class NostrClient(
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = activeCounts.getSubscriptionFiltersOrNull(subId)
override fun relayStatusFlow() = relayPool.statusFlow
override fun connectedRelaysFlow() = relayPool.connectedRelays
override fun availableRelaysFlow() = relayPool.availableRelays
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
@@ -31,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* RelayPool manages a collection of Nostr relays, abstracting individual connections and providing
@@ -61,9 +60,11 @@ class RelayPool(
) : IRelayClientListener {
private val relays = LargeCache<NormalizedRelayUrl, IRelayClient>()
// Backing property to avoid flow emissions from other classes
private val _statusFlow = MutableStateFlow<RelayPoolStatus>(RelayPoolStatus())
val statusFlow: StateFlow<RelayPoolStatus> = _statusFlow.asStateFlow()
private val _connectedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val connectedRelays = _connectedRelays.asStateFlow()
private val _availableRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val availableRelays = _availableRelays.asStateFlow()
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
@@ -87,21 +88,18 @@ class RelayPool(
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays)
}
}
updateStatus()
}
fun connect() {
relays.forEach { url, relay ->
relay.connect()
}
updateStatus()
}
fun connectIfDisconnected() {
relays.forEach { url, relay ->
relay.connectAndSyncFiltersIfDisconnected()
}
updateStatus()
}
fun connectIfDisconnected(relay: NormalizedRelayUrl) = relays.get(relay)?.connectAndSyncFiltersIfDisconnected()
@@ -110,7 +108,6 @@ class RelayPool(
relays.forEach { url, relay ->
relay.disconnect()
}
updateStatus()
}
fun sendOrConnectAndSync(
@@ -144,7 +141,13 @@ class RelayPool(
// --------------------
// Pool Maintenance
// --------------------
fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, ::createNewRelay)
fun getOrCreateRelay(relay: NormalizedRelayUrl): IRelayClient {
val r = relays.getOrCreate(relay, ::createNewRelay)
if (_availableRelays.value.size != relays.size()) {
_availableRelays.update { relays.keys() }
}
return r
}
fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, ::createNewRelay)
@@ -155,39 +158,40 @@ class RelayPool(
val toRemove = relays.keys() - newRelays
var atLeastOne = false
newRelays.forEach {
if (createRelayIfAbsent(it)) {
newRelays.forEach { relay ->
if (createRelayIfAbsent(relay)) {
atLeastOne = true
}
}
toRemove.forEach {
if (removeRelayInner(it)) {
toRemove.forEach { relay ->
if (removeRelayInner(relay)) {
atLeastOne = true
}
}
if (atLeastOne) {
updateStatus()
_availableRelays.update { relays.keys() }
}
}
fun addRelay(relay: NormalizedRelayUrl): IRelayClient {
if (createRelayIfAbsent(relay)) {
updateStatus()
_availableRelays.update { relays.keys() }
}
return getOrCreateRelay(relay)
}
fun addAllRelays(relayList: List<NormalizedRelayUrl>) {
var atLeastOne = false
relayList.forEach {
if (createRelayIfAbsent(it)) {
relayList.forEach { relay ->
if (createRelayIfAbsent(relay)) {
atLeastOne = true
}
}
if (atLeastOne) {
updateStatus()
_availableRelays.update { relays.keys() }
}
}
@@ -202,7 +206,7 @@ class RelayPool(
fun removeRelay(relay: NormalizedRelayUrl) {
if (removeRelayInner(relay)) {
updateStatus()
_availableRelays.update { relays.keys() }
}
}
@@ -210,7 +214,7 @@ class RelayPool(
if (relays.size() > 0) {
disconnect()
relays.clear()
updateStatus()
_availableRelays.update { emptySet() }
}
}
@@ -224,12 +228,12 @@ class RelayPool(
pingMillis: Int,
compressed: Boolean,
) {
updateStatus()
_connectedRelays.update { it + relay.url }
listener.onConnected(relay, pingMillis, compressed)
}
override fun onDisconnected(relay: IRelayClient) {
updateStatus()
_connectedRelays.update { it - relay.url }
listener.onDisconnected(relay)
}
@@ -251,33 +255,5 @@ class RelayPool(
success: Boolean,
) = listener.onSent(relay, cmdStr, cmd, success)
// ---------------
// STATUS Reports
// ---------------
fun availableRelays(): Set<NormalizedRelayUrl> = relays.keys()
fun connectedRelays(): Set<NormalizedRelayUrl> =
relays.mapNotNullIntoSet { url, relay ->
if (relay.isConnected()) {
url
} else {
null
}
}
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()
if (_statusFlow.value.connected != connected || _statusFlow.value.available != available) {
_statusFlow.tryEmit(RelayPoolStatus(connected, available))
}
}
@Immutable
data class RelayPoolStatus(
val connected: Set<NormalizedRelayUrl> = emptySet(),
val available: Set<NormalizedRelayUrl> = emptySet(),
val isConnected: Boolean = connected.isNotEmpty(),
)
fun connectedRelaysCount(): Int = relays.count { url, relay -> relay.isConnected() }
}
@@ -89,10 +89,7 @@ object Nip19Parser {
if (uri == null) return null
try {
val matcher = nip19PlusNip46regex.find(uri)
if (matcher == null) {
return null
}
val matcher = nip19PlusNip46regex.find(uri) ?: return null
val type = matcher.groups[3]?.value ?: matcher.groups[5]?.value // npub1
val key = matcher.groups[4]?.value ?: matcher.groups[6]?.value // bech32
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
class Nip04DecryptResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<DecryptionResult> =
when (response) {
is BunkerResponseDecrypt -> {
SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
class Nip04EncryptResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<EncryptionResult> =
when (response) {
is BunkerResponseEncrypt -> {
SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
class Nip44DecryptResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<DecryptionResult> =
when (response) {
is BunkerResponseDecrypt -> {
SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
class Nip44EncryptResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<EncryptionResult> =
when (response) {
is BunkerResponseEncrypt -> {
SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,314 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
class NostrSignerRemote(
val signer: NostrSignerInternal,
val remotePubkey: HexKey,
val relays: Set<NormalizedRelayUrl>,
val client: INostrClient,
val permissions: String? = null,
val secret: String? = null,
) : NostrSigner(signer.pubKey) {
private val manager =
RemoteSignerManager(
signer = signer,
remoteKey = remotePubkey,
relayList = relays,
client = client,
)
val subscription =
client.req(
relays = relays.toList(),
filter =
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(signer.pubKey)),
),
) { event ->
manager.newResponse(event)
}
fun openSubscription() {
subscription.updateFilter()
}
fun closeSubscription() {
subscription.close()
}
override fun isWriteable(): Boolean = true
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
val template =
EventTemplate<Event>(
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
)
BunkerRequestSign(
event = template,
)
},
parser = SignResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<SignResult>) {
(result.result.event as? T)?.let {
return it
}
}
throw convertExceptions("Could not sign", result)
}
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestNip04Encrypt(
message = plaintext,
pubKey = toPublicKey,
)
},
parser = Nip04EncryptResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<EncryptionResult>) {
return result.result.ciphertext
}
throw convertExceptions("Could not encrypt", result)
}
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestNip04Decrypt(
ciphertext = ciphertext,
pubKey = fromPublicKey,
)
},
parser = Nip04DecryptResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<DecryptionResult>) {
return result.result.plaintext
}
throw convertExceptions("Could not decrypt", result)
}
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestNip44Encrypt(
message = plaintext,
pubKey = toPublicKey,
)
},
parser = Nip44EncryptResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<EncryptionResult>) {
return result.result.ciphertext
}
throw convertExceptions("Could not encrypt", result)
}
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestNip44Decrypt(
ciphertext = ciphertext,
pubKey = fromPublicKey,
)
},
parser = Nip44DecryptResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<DecryptionResult>) {
return result.result.plaintext
}
throw convertExceptions("Could not decrypt", result)
}
suspend fun ping(): String {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestPing()
},
parser = PingResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<PingResult>) {
return result.result.pong
}
throw convertExceptions("Could not ping", result)
}
suspend fun connect(): HexKey {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestConnect(
remoteKey = remotePubkey,
permissions = permissions,
secret = secret,
)
},
parser = PubKeyResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
return result.result.pubkey
}
throw convertExceptions("Could not connect", result)
}
suspend fun getPublicKey(): HexKey {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
BunkerRequestGetPublicKey()
},
parser = PubKeyResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
return result.result.pubkey
}
throw convertExceptions("Could not get public key", result)
}
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent {
TODO("Not yet implemented")
}
override suspend fun deriveKey(nonce: HexKey): HexKey {
TODO("Not yet implemented")
}
override fun hasForegroundSupport(): Boolean = true
fun convertExceptions(
title: String,
result: SignerResult.RequestAddressed<*>,
): Exception =
when (result) {
is SignerResult.RequestAddressed.Successful<*> -> IllegalStateException("$title: This should not happen. There is a bug on Quartz.")
is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.")
is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.")
is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}")
is SignerResult.RequestAddressed.Rejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.")
is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.")
}
companion object {
fun fromBunkerUri(
bunkerUri: String,
signer: NostrSignerInternal,
client: INostrClient,
permissions: String? = null,
): NostrSignerRemote {
if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri")
val splitData = bunkerUri.split("?")
val remotePubkey = splitData[0].removePrefix("bunker://")
if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri")
val params = splitData[1].split("&")
val relays = mutableSetOf<NormalizedRelayUrl>()
var secret: String? = null
for (param in params) {
val splitParam = param.split("=")
if (splitParam.size < 2) continue
if (splitParam.first() == "relay") {
relays.add(NormalizedRelayUrl(splitParam[1]))
}
if (splitParam.first() == "secret") {
secret = splitParam[1]
}
}
return NostrSignerRemote(
signer = signer,
remotePubkey = remotePubkey,
relays = relays,
client = client,
permissions = permissions,
secret = secret,
)
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
class PingResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<PingResult> =
when (response) {
is BunkerResponsePong -> {
SignerResult.RequestAddressed.Successful(PingResult(response.id))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey
class PubKeyResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<PublicKeyResult> =
when (response) {
is BunkerResponsePublicKey -> {
SignerResult.RequestAddressed.Successful(PublicKeyResult(response.pubkey))
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
}
else -> {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.cache.LargeCache
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class RemoteSignerManager(
val timeout: Long = 30000,
val client: INostrClient,
val signer: NostrSignerInternal,
val remoteKey: String,
val relayList: Set<NormalizedRelayUrl>,
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
fun newResponse(responseEvent: Event) {
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
}
/**
* Launches the signer, waits and parses the result
*
* @param bunkerRequestBuilder The BunkerRequest to be sent.
* @param parser A function that parses the BunkerResponse into a [SignerResult.RequestAddressed<T>].
* @return The result after parsing the BunkerResponse using the provided parser.
*
* This function uses the [tryAndWait] utility to implement a timeout on the Bunker Request approval.
* It assigns a unique ID to the request and keeps a continuation to resume once the result is received.
* If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests].
*/
suspend fun <T : IResult> launchWaitAndParse(
bunkerRequestBuilder: () -> BunkerRequest,
parser: (response: BunkerResponse) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> {
val request = bunkerRequestBuilder()
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remoteKey,
signer = signer,
)
val result =
tryAndWait(timeout) { continuation ->
continuation.invokeOnCancellation {
awaitingRequests.remove(request.id)
}
awaitingRequests.put(request.id, continuation)
client.send(event, relayList = relayList)
}
return when (result) {
null -> SignerResult.RequestAddressed.TimedOut()
else -> parser(result)
}
}
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent
class SignResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<SignResult> =
if (response is BunkerResponseEvent) {
if (!response.event.verify()) {
SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(response.event)
} else {
SignerResult.RequestAddressed.Successful(SignResult(response.event))
}
} else if (response is BunkerResponseError) {
SignerResult.RequestAddressed.Rejected()
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(
val result: T,
) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String? = null,
) : RequestAddressed<T>
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
val eventJson: String,
) : RequestAddressed<T>
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
val invalidEvent: Event,
) : RequestAddressed<T>
}
}
interface IResult
data class SignResult(
val event: Event,
) : IResult
data class EncryptionResult(
val ciphertext: String,
) : IResult
data class DecryptionResult(
val plaintext: String,
) : IResult
data class PingResult(
val pong: String,
) : IResult
data class PublicKeyResult(
val pubkey: String,
) : IResult
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.utils.startsWith
import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
inline fun TagArray.filterToArray(predicate: (Array<String>) -> Boolean): TagArray = filterTo(ArrayList(), predicate).toTypedArray()
@@ -31,6 +32,8 @@ inline fun TagArray.remove(predicate: (Array<String>) -> Boolean): TagArray = fi
fun TagArray.remove(startsWith: Array<String>): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray()
fun TagArray.removeIgnoreCase(startsWith: Array<String>): TagArray = filterNotTo(ArrayList(this.size), { it.startsWithIgnoreCase(startsWith) }).toTypedArray()
fun <R> TagArray.removeParsing(
transform: (Tag) -> R,
equalsTo: R,
@@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.nip51Lists.removeIgnoreCase
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -140,8 +140,8 @@ class HashtagListEvent(
): HashtagListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(HashtagTag.assemble(hashtag)),
tags = earlierVersion.tags.remove(HashtagTag.assemble(hashtag)),
privateTags = privateTags.removeIgnoreCase(HashtagTag.assemble(hashtag)),
tags = earlierVersion.tags.removeIgnoreCase(HashtagTag.assemble(hashtag)),
signer = signer,
createdAt = createdAt,
)
@@ -42,18 +42,14 @@ class IMetaTag(
const val TAG_NAME = "imeta"
const val ANCHOR_PROPERTY = "url"
fun parse(tag: Array<String>): IMetaTag? {
fun parse(tag: Array<String>): List<IMetaTag>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val allTags = parseIMeta(tag)
val url = allTags[ANCHOR_PROPERTY]?.firstOrNull()
return if (url != null) {
IMetaTag(url, allTags.minus(ANCHOR_PROPERTY))
} else {
null
return allTags[ANCHOR_PROPERTY]?.map {
IMetaTag(it, allTags.minus(ANCHOR_PROPERTY))
}
}
@@ -22,6 +22,6 @@ package com.vitorpamplona.quartz.nip92IMeta
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.imetas() = this.mapNotNull(IMetaTag::parse)
fun TagArray.imetas() = this.mapNotNull(IMetaTag::parse).flatten()
fun TagArray.imetasByUrl() = this.imetas().associateBy { it.url }
@@ -40,6 +40,14 @@ fun Array<String>.startsWith(startsWith: Array<String>): Boolean {
return true
}
fun Array<String>.startsWithIgnoreCase(startsWith: Array<String>): Boolean {
if (startsWith.size > this.size) return false
for (tagIdx in startsWith.indices) {
if (!startsWith[tagIdx].equals(this[tagIdx], ignoreCase = true)) return false
}
return true
}
fun Array<String>.startsWithAny(startsWithList: List<Array<String>>): Boolean = startsWithList.any { startsWith(it) }
public inline fun <T, R> Array<out T>.lastNotNullOfOrNull(transform: (T) -> R?): R? {
@@ -147,6 +147,18 @@ class NIP19ParserTest {
)
}
@Test
fun nAddrIncompleteParser() {
val result =
Nip19Parser.uriToRoute(
"nostr:naddr1qqqqzxthwden5te0wfjkccte9ejxjanfdejjuanfv3jk7tczyrv4428upmlcujyf2fy4hqrynywj07ukakr99ufvmmw95n5tttj5qqcyqqqgt0qeresua",
)
assertEquals(
"34236:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:",
(result?.entity as? NAddress)?.aTag(),
)
}
@Test
fun nAddrParser2() {
val result =
+10 -13
View File
@@ -1,16 +1,13 @@
amethyst:
android:
name: Amethyst
description: The all-in-one Nostr client
license: MIT
builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0
repository: https://github.com/vitorpamplona/amethyst
artifacts:
- amethyst-googleplay-arm64-v8a-v%v.apk
- amethyst-googleplay-armeabi-v7a-v%v.apk
- amethyst-googleplay-universal-v%v.apk
- amethyst-googleplay-x86-v%v.apk
- amethyst-googleplay-x86_64-v%v.apk
name: Amethyst
description: The all-in-one Nostr client
license: MIT
builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0
repository: https://github.com/vitorpamplona/amethyst
homepage: https://amethyst.social/
assets:
- amethyst-googleplay.*.apk
remote_metadata:
- github