Merge branch 'main' of https://github.com/vitorpamplona/amethyst into vitorpamplona-main

merging upstream changes.
This commit is contained in:
KotlinGeekDev
2024-10-31 19:36:20 +01:00
56 changed files with 1092 additions and 550 deletions
+3 -1
View File
@@ -30,4 +30,6 @@ If applicable, add a video and/or screenshots to help explain your problem.
- Amber Version (if using it to sign): - Amber Version (if using it to sign):
**Bounty (in Bitcoin sats) offered for a solution** **Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment. The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this bug will not be worked on because it doesn't matter to you. We prioritize
bug fixing to issues that have bounties, even small ones.
+3 -7
View File
@@ -7,14 +7,10 @@ assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation** **Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment. The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this feature will not be coded because it doesn't actually matter to you.
**Additional context** We prioritize feature development by its bounty size compared to how much work is required to get it done.
Add any other context, video, or screenshots about the feature request here.
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" /> <option name="version" value="2.0.20" />
</component> </component>
</project> </project>
@@ -32,6 +32,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader import coil.ImageLoader
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.playback.VideoCache import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -50,6 +51,7 @@ class Amethyst : Application() {
// Service Manager is only active when the activity is active. // Service Manager is only active when the activity is active.
val serviceManager = ServiceManager(applicationIOScope) val serviceManager = ServiceManager(applicationIOScope)
val locationManager = LocationState(this, applicationIOScope)
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import android.location.Location
import android.util.Log import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -28,6 +29,8 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
@@ -120,10 +123,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -160,17 +161,19 @@ class Account(
val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet()) val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet())
@Immutable @Immutable
class LiveFollowLists( class LiveFollowList(
val users: Set<String> = emptySet(), val authors: Set<String> = emptySet(),
val usersPlusMe: Set<String>, val authorsPlusMe: Set<String>,
val hashtags: Set<String> = emptySet(), val hashtags: Set<String> = emptySet(),
val geotags: Set<String> = emptySet(), val geotags: Set<String> = emptySet(),
val communities: Set<String> = emptySet(), val addresses: Set<String> = emptySet(),
) )
class ListNameNotePair( class FeedsBaseFlows(
val listName: String, val listName: String,
val event: GeneralListEvent?, val peopleList: StateFlow<NoteState> = MutableStateFlow(NoteState(Note(" "))),
val kind3: StateFlow<Account.LiveFollowList?> = MutableStateFlow(null),
val location: StateFlow<Location?> = MutableStateFlow(null),
) )
val connectToRelaysFlow = val connectToRelaysFlow =
@@ -218,7 +221,7 @@ class Account(
localRelayList: Set<String>, localRelayList: Set<String>,
): List<RelaySetupInfo> { ): List<RelaySetupInfo> {
val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayEvent?.relays() ?: Constants.defaultSearchRelaySet).map { RelayUrlFormatter.normalize(it) }.toSet() val searchRelaySet = (searchRelayEvent?.relays() ?: DefaultSearchRelayList).map { RelayUrlFormatter.normalize(it) }.toSet()
val nip65RelaySet = val nip65RelaySet =
nip65RelayEvent?.relays()?.map { nip65RelayEvent?.relays()?.map {
AdvertisedRelayListEvent.AdvertisedRelayInfo( AdvertisedRelayListEvent.AdvertisedRelayInfo(
@@ -465,23 +468,26 @@ class Account(
}.toTypedArray(), }.toTypedArray(),
) )
fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists { fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList {
// makes sure the output include only valid p tags // makes sure the output include only valid p tags
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
return LiveFollowLists( return LiveFollowList(
verifiedFollowingUsers, authors = verifiedFollowingUsers,
verifiedFollowingUsers + signer.pubKey, authorsPlusMe = verifiedFollowingUsers + signer.pubKey,
latestContactList hashtags =
?.unverifiedFollowTagSet() latestContactList
?.map { it.lowercase() } ?.unverifiedFollowTagSet()
?.toSet() ?: emptySet(), ?.map { it.lowercase() }
latestContactList ?.toSet() ?: emptySet(),
?.unverifiedFollowGeohashSet() geotags =
?.toSet() ?: emptySet(), latestContactList
latestContactList ?.unverifiedFollowGeohashSet()
?.verifiedFollowAddressSet() ?.toSet() ?: emptySet(),
?.toSet() ?: emptySet(), addresses =
latestContactList
?.verifiedFollowAddressSet()
?.toSet() ?: emptySet(),
) )
} }
@@ -516,7 +522,7 @@ class Account(
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowLists> = val liveKind3FollowsFlow: Flow<LiveFollowList> =
userProfile().flow().follows.stateFlow.transformLatest { userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread() checkNotInMainThread()
emit(buildFollowLists(it.user.latestContactList)) emit(buildFollowLists(it.user.latestContactList))
@@ -531,80 +537,102 @@ class Account(
buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), buildFollowLists(userProfile().latestContactList ?: settings.backupContactList),
) )
@OptIn(ExperimentalCoroutinesApi::class) fun loadFlowsFor(listName: String): FeedsBaseFlows =
private val liveHomeList: Flow<ListNameNotePair> = when (listName) {
settings.defaultHomeFollowList.flatMapLatest { listName -> GLOBAL_FOLLOWS -> FeedsBaseFlows(listName)
loadPeopleListFlowFromListName(listName) KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows)
} AROUND_ME ->
FeedsBaseFlows(
fun peopleListFromListNameStarter(listName: String): ListNameNotePair = listName,
if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { location = Amethyst.instance.locationManager.locationStateFlow,
val note = LocalCache.checkGetOrCreateAddressableNote(listName) )
val noteEvent = note?.event as? GeneralListEvent else -> {
ListNameNotePair(listName, noteEvent) val note = LocalCache.checkGetOrCreateAddressableNote(listName)
} else { if (note != null) {
ListNameNotePair(listName, null) FeedsBaseFlows(
} listName,
peopleList =
@OptIn(ExperimentalCoroutinesApi::class) note
fun loadPeopleListFlowFromListName(listName: String): Flow<ListNameNotePair> = .flow()
if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { .metadata.stateFlow,
val note = LocalCache.checkGetOrCreateAddressableNote(listName) )
note?.flow()?.metadata?.stateFlow?.mapLatest { } else {
val noteEvent = it.note.event as? GeneralListEvent FeedsBaseFlows(listName)
ListNameNotePair(listName, noteEvent) }
} ?: MutableStateFlow(ListNameNotePair(listName, null))
} else {
MutableStateFlow(ListNameNotePair(listName, null))
}
suspend fun combinePeopleList(
kind3Follows: LiveFollowLists,
peopleListFollows: ListNameNotePair,
): LiveFollowLists? =
if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
null
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
kind3Follows
} else if (peopleListFollows.event == null) {
LiveFollowLists(usersPlusMe = setOf(signer.pubKey))
} else {
val result = waitToDecrypt(peopleListFollows.event)
if (result == null) {
LiveFollowLists(usersPlusMe = setOf(signer.pubKey))
} else {
result
} }
} }
fun combinePeopleListFlows( suspend fun mapIntoFollowLists(
kind3FollowsSource: Flow<LiveFollowLists>, listName: String,
peopleListFollowsSource: Flow<ListNameNotePair>, kind3: LiveFollowList?,
): Flow<LiveFollowLists?> = noteState: NoteState,
combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows -> location: Location?,
checkNotInMainThread() ): LiveFollowList? =
emit(combinePeopleList(kind3Follows, peopleListFollows)) if (listName == GLOBAL_FOLLOWS) {
null
} else if (listName == KIND3_FOLLOWS) {
kind3
} else if (listName == AROUND_ME) {
val hash = location?.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)
if (hash != null) {
// 2 neighbors deep = 25x25km
val hashes =
listOf(hash.toString()) +
hash.adjacent
.map { listOf(it.toString()) + it.adjacent.map { it.toString() } }
.flatten()
.distinct()
LiveFollowList(
authorsPlusMe = setOf(signer.pubKey),
geotags = hashes.toSet(),
)
} else {
LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
}
} else {
val peopleList = noteState.note.event as? GeneralListEvent
if (peopleList != null) {
waitToDecrypt(peopleList) ?: LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
} else {
LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
}
} }
val liveHomeFollowListFlow: Flow<LiveFollowLists?> by lazy { @OptIn(ExperimentalCoroutinesApi::class)
combinePeopleListFlows(liveKind3Follows, liveHomeList) fun combinePeopleListFlows(peopleListFollowsSource: Flow<String>): Flow<LiveFollowList?> =
} peopleListFollowsSource
.transformLatest { listName ->
val followList = loadFlowsFor(listName)
emitAll(
combine(followList.kind3, followList.peopleList, followList.location) { kind3, peopleList, location ->
mapIntoFollowLists(followList.listName, kind3, peopleList, location)
},
)
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy { val liveHomeFollowLists: StateFlow<LiveFollowList?> by lazy {
liveHomeFollowListFlow combinePeopleListFlows(settings.defaultHomeFollowList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultHomeFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultHomeFollowList.value),
)
}, },
) )
} }
suspend fun loadAndCombineFlows(listName: String): LiveFollowList? {
val flows = loadFlowsFor(listName)
return mapIntoFollowLists(
flows.listName,
flows.kind3.value,
flows.peopleList.value,
flows.location.value,
)
}
/** /**
* filter onion and local host from write relays * filter onion and local host from write relays
* for each user pubkey, a list of valid relays. * for each user pubkey, a list of valid relays.
@@ -701,7 +729,7 @@ class Account(
liveHomeFollowLists liveHomeFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -728,53 +756,33 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveHomeFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveNotificationFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveNotificationList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultNotificationFollowList)
settings.defaultNotificationFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultNotificationFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultNotificationFollowList.value),
)
}, },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveStoriesFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveStoriesList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultStoriesFollowList)
settings.defaultStoriesFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultStoriesFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultStoriesFollowList.value),
)
}, },
) )
} }
@@ -784,7 +792,7 @@ class Account(
liveStoriesFollowLists liveStoriesFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -811,31 +819,21 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveStoriesFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveStoriesFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveDiscoveryFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultDiscoveryFollowList)
settings.defaultDiscoveryFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultDiscoveryFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultDiscoveryFollowList.value),
)
}, },
) )
} }
@@ -845,7 +843,7 @@ class Account(
liveDiscoveryFollowLists liveDiscoveryFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -872,7 +870,7 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.read }.map { it.url }, connectToRelays.value.filter { it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
@@ -881,19 +879,17 @@ class Account(
private fun decryptLiveFollows( private fun decryptLiveFollows(
listEvent: GeneralListEvent, listEvent: GeneralListEvent,
onReady: (LiveFollowLists) -> Unit, onReady: (LiveFollowList) -> Unit,
) { ) {
listEvent.privateTags(signer) { privateTagList -> listEvent.privateTags(signer) { privateTagList ->
val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet() val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet()
onReady( onReady(
LiveFollowLists( LiveFollowList(
users = users, authors = users,
usersPlusMe = users + userProfile().pubkeyHex, authorsPlusMe = users + userProfile().pubkeyHex,
hashtags = hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(),
(listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(),
geotags = addresses =
(listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(),
communities =
(listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList))
.map { it.toTag() } .map { it.toTag() }
.toSet(), .toSet(),
@@ -902,7 +898,7 @@ class Account(
} }
} }
suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowLists? = suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? =
withTimeoutOrNull(1000) { withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
decryptLiveFollows(peopleListFollows) { decryptLiveFollows(peopleListFollows) {
@@ -3210,7 +3206,7 @@ class Account(
flowHiddenUsers.value.hiddenUsers.contains(userHex) || flowHiddenUsers.value.hiddenUsers.contains(userHex) ||
flowHiddenUsers.value.spammers.contains(userHex) flowHiddenUsers.value.spammers.contains(userHex)
fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.users fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.authors
fun isAcceptable(user: User): Boolean { fun isAcceptable(user: User): Boolean {
if (userProfile().pubkeyHex == user.pubkeyHex) { if (userProfile().pubkeyHex == user.pubkeyHex) {
@@ -3268,8 +3264,8 @@ class Account(
} }
return ( return (
note.reportsBy(liveKind3Follows.value.usersPlusMe) + note.reportsBy(liveKind3Follows.value.authorsPlusMe) +
(note.author?.reportsBy(liveKind3Follows.value.usersPlusMe) ?: emptyList()) + (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) +
innerReports innerReports
).toSet() ).toSet()
} }
@@ -67,9 +67,9 @@ val DefaultNIP65List =
val DefaultDMRelayList = val DefaultDMRelayList =
listOf( listOf(
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"), RelayUrlFormatter.normalize("wss://auth.nostr1.com"),
RelayUrlFormatter.normalize("wss://nostr.mom/"), RelayUrlFormatter.normalize("wss://relay.0xchat.com"),
RelayUrlFormatter.normalize("wss://nos.lol/"), RelayUrlFormatter.normalize("wss://nos.lol"),
) )
val DefaultSearchRelayList = val DefaultSearchRelayList =
@@ -77,6 +77,7 @@ val DefaultSearchRelayList =
RelayUrlFormatter.normalize("wss://relay.nostr.band"), RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"), RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"), RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
RelayUrlFormatter.normalize("wss://search.nos.today"),
) )
// This has spaces to avoid mixing with a potential NIP-51 list with the same name. // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
@@ -85,6 +86,9 @@ val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name. // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows " val KIND3_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@Stable @Stable
class AccountSettings( class AccountSettings(
val keyPair: KeyPair, val keyPair: KeyPair,
@@ -357,7 +361,6 @@ class AccountSettings(
// Events might be different objects, we have to compare their ids. // Events might be different objects, we have to compare their ids.
if (backupAppSpecificData?.id != appSettings.id) { if (backupAppSpecificData?.id != appSettings.id) {
println("AABBCC Update App Specific Data")
backupAppSpecificData = appSettings backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings) syncedSettings.updateFrom(newSyncedSettings)
@@ -2043,7 +2043,10 @@ object LocalCache {
} }
} }
fun findUsersStartingWith(username: String): List<User> { fun findUsersStartingWith(
username: String,
forAccount: Account?,
): List<User> {
checkNotInMainThread() checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username) val key = decodePublicKeyAsHexOrNull(username)
@@ -2056,26 +2059,19 @@ object LocalCache {
} }
return users.filter { _, user: User -> return users.filter { _, user: User ->
(user.anyNameStartsWith(username)) || (
user.pubkeyHex.startsWith(username, true) || (user.anyNameStartsWith(username)) ||
user.pubkeyNpub().startsWith(username, true) user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
(forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase)))
} }
} }
fun getFollowSetsFor(user: User): List<AddressableNote> { fun findNotesStartingWith(
checkNotInMainThread() text: String,
forAccount: Account,
return addressables ): List<Note> {
.filter { _, note ->
val listEvent = note.event
(
listEvent is PeopleListEvent &&
user.pubkeyHex == listEvent.pubKey
)
}
}
fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread() checkNotInMainThread()
val key = decodeEventIdAsHexOrNull(text) val key = decodeEventIdAsHexOrNull(text)
@@ -2102,11 +2098,19 @@ object LocalCache {
note.idHex.startsWith(text, true) || note.idHex.startsWith(text, true) ||
note.idNote().startsWith(text, true) note.idNote().startsWith(text, true)
) { ) {
return@filter true if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter true
} else {
return@filter false
}
} }
if (note.event?.isContentEncoded() == false) { if (note.event?.isContentEncoded() == false) {
return@filter note.event?.content()?.contains(text, true) ?: false if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter note.event?.content()?.contains(text, true) ?: false
} else {
return@filter false
}
} }
return@filter false return@filter false
@@ -2125,11 +2129,19 @@ object LocalCache {
if (addressable.event?.matchTag1With(text) == true || if (addressable.event?.matchTag1With(text) == true ||
addressable.idHex.startsWith(text, true) addressable.idHex.startsWith(text, true)
) { ) {
return@filter true if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter true
} else {
return@filter false
}
} }
if (addressable.event?.isContentEncoded() == false) { if (addressable.event?.isContentEncoded() == false) {
return@filter addressable.event?.content()?.contains(text, true) ?: false if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter addressable.event?.content()?.contains(text, true) ?: false
} else {
return@filter false
}
} }
return@filter false return@filter false
@@ -771,29 +771,11 @@ open class Note(
return true return true
} }
if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) { if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
return true return true
} }
if (author?.profilePicture()?.containsAny(accountChoices.hiddenWordsCase) == true) { if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true
return true
}
if (author?.info?.banner?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.about?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud06?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud16?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
} }
return false return false
@@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -371,6 +373,36 @@ class User(
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type } (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null } != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
return true
}
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
return false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null var liveSet: UserLiveSet? = null
@@ -26,75 +26,93 @@ import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationListener import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.os.HandlerThread import android.os.Looper
import android.util.Log
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.MutableState import com.fonfon.kgeohash.toGeoHash
import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class LocationUtil( class LocationFlow(
context: Context, private val context: Context,
) { ) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun start( fun get(
minTimeMs: Long = MIN_TIME, minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE, minDistanceM: Float = MIN_DISTANCE,
) { ): Flow<Location> =
locationListener().let { callbackFlow {
locationListener = it val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
launch { send(location) }
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
Log.d("Location Service", "LocationState Start")
locationManager.requestLocationUpdates( locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER, LocationManager.NETWORK_PROVIDER,
minTimeMs, minTimeMs,
minDistanceM, minDistanceM,
it, locationCallback,
locHandlerThread.looper, Looper.getMainLooper(),
) )
}
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() { awaitClose {
locationListener?.let { locationManager.removeUpdates(it) } locationManager.removeUpdates(locationCallback)
isStart.value = false Log.d("Location Service", "LocationState Stop")
}
private fun locationListener() =
object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
} }
} }
} }
class LocationState(
context: Context,
scope: CoroutineScope,
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
}
private var latestLocation: Location = Location(LocationManager.NETWORK_PROVIDER)
val locationStateFlow =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onEach {
latestLocation = it
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
val geohashStateFlow =
locationStateFlow
.map { it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits).toString() }
.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
"",
)
}
object CachedGeoLocations { object CachedGeoLocations {
val locationNames = LruCache<String, String>(20) val locationNames = LruCache<String, String>(20)
@@ -162,7 +162,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
fun createLiveStreamFilter(): List<TypedFilter> { fun createLiveStreamFilter(): List<TypedFilter> {
val follows = val follows =
account.liveDiscoveryFollowLists.value account.liveDiscoveryFollowLists.value
?.users ?.authors
?.toList() ?.toList()
?.ifEmpty { null } ?.ifEmpty { null }
@@ -211,7 +211,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
mapOf( mapOf(
"g" to "g" to
hashToLoad hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } .map { listOf(it.lowercase()) }
.flatten(), .flatten(),
), ),
limit = 100, limit = 100,
@@ -225,7 +225,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
} }
fun createFollowCommunitiesFilter(): TypedFilter? { fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null
if (communitiesToLoad.isEmpty()) return null if (communitiesToLoad.isEmpty()) return null
@@ -274,7 +274,7 @@ open class EditPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache LocalCache
.findUsersStartingWith(lastWord.removePrefix("@")) .findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed() .reversed()
} }
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
@@ -78,7 +78,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -163,8 +166,7 @@ open class NewPostViewModel : ViewModel() {
// GeoHash // GeoHash
var wantsToAddGeoHash by mutableStateOf(false) var wantsToAddGeoHash by mutableStateOf(false)
var locUtil: LocationUtil? = null var location: StateFlow<String?>? = null
var location: Flow<String>? = null
// ZapRaiser // ZapRaiser
var canAddZapRaiser by mutableStateOf(false) var canAddZapRaiser by mutableStateOf(false)
@@ -530,14 +532,7 @@ open class NewPostViewModel : ViewModel() {
null null
} }
val geoLocation = locUtil?.locationStateFlow?.value val geoHash = location?.value
val geoHash =
if (wantsToAddGeoHash && geoLocation != null) {
geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
} else {
null
}
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach { nip95attachments.forEach {
@@ -1002,7 +997,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache LocalCache
.findUsersStartingWith(lastWord.removePrefix("@")) .findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed() .reversed()
} }
@@ -1031,7 +1026,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache LocalCache
.findUsersStartingWith(lastWord.removePrefix("@")) .findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed() .reversed()
} }
@@ -1059,7 +1054,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache LocalCache
.findUsersStartingWith(lastWord.removePrefix("@")) .findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith( .sortedWith(
compareBy( compareBy(
{ account?.isFollowing(it) }, { account?.isFollowing(it) },
@@ -1262,28 +1257,20 @@ open class NewPostViewModel : ViewModel() {
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun startLocation(context: Context) { fun locationFlow(): Flow<String?> {
locUtil = LocationUtil(context) if (location == null) {
locUtil?.let {
location = location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } Amethyst.instance.locationManager.locationStateFlow
saveDraft() .mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
} }
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
fun stopLocation() { return location!!
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
} }
fun toggleNIP04And24() { fun toggleNIP04And24() {
@@ -1386,7 +1373,7 @@ open class NewPostViewModel : ViewModel() {
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
} }
} }
elementList.removeLast() elementList.removeAt(elementList.size - 1)
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
this.clear() this.clear()
this.putAll(newEntries) this.putAll(newEntries)
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -42,6 +43,8 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
@@ -50,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -66,6 +70,7 @@ fun AddDMRelayListDialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
SetDialogToEdgeToEdge()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -114,7 +119,7 @@ fun AddDMRelayListDialog(
), ),
verticalArrangement = Arrangement.SpaceAround, verticalArrangement = Arrangement.SpaceAround,
) { ) {
Explanation() Explanation(postViewModel)
DMRelayList(postViewModel, accountViewModel, onClose, nav) DMRelayList(postViewModel, accountViewModel, onClose, nav)
} }
@@ -123,7 +128,7 @@ fun AddDMRelayListDialog(
} }
@Composable @Composable
private fun Explanation() { private fun Explanation(postViewModel: DMRelayListViewModel) {
Card(modifier = MaterialTheme.colorScheme.imageModifier) { Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
@@ -133,8 +138,25 @@ private fun Explanation() {
Spacer(modifier = StdVertSpacer) Spacer(modifier = StdVertSpacer)
Text( Text(
text = stringRes(id = R.string.dm_relays_not_found_examples), text = stringRes(id = R.string.dm_relays_not_found_examples2),
) )
Spacer(modifier = StdVertSpacer)
ResetDMRelaysLonger(postViewModel)
} }
} }
} }
@Composable
fun ResetDMRelaysLonger(postViewModel: DMRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays_longer))
}
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -42,6 +43,8 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
@@ -50,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -66,6 +70,7 @@ fun AddSearchRelayListDialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
SetDialogToEdgeToEdge()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -114,7 +119,7 @@ fun AddSearchRelayListDialog(
), ),
verticalArrangement = Arrangement.SpaceAround, verticalArrangement = Arrangement.SpaceAround,
) { ) {
Explanation() Explanation(postViewModel)
SearchRelayList(postViewModel, accountViewModel, onClose, nav) SearchRelayList(postViewModel, accountViewModel, onClose, nav)
} }
@@ -123,7 +128,7 @@ fun AddSearchRelayListDialog(
} }
@Composable @Composable
private fun Explanation() { private fun Explanation(postViewModel: SearchRelayListViewModel) {
Card(modifier = MaterialTheme.colorScheme.imageModifier) { Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
@@ -135,6 +140,23 @@ private fun Explanation() {
Text( Text(
text = stringRes(id = R.string.search_relays_not_found_examples), text = stringRes(id = R.string.search_relays_not_found_examples),
) )
Spacer(modifier = StdVertSpacer)
ResetSearchRelaysLonger(postViewModel)
} }
} }
} }
@Composable
fun ResetSearchRelaysLonger(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays_longer))
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -49,6 +50,8 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -171,91 +174,92 @@ fun MappedAllRelayListView(
) )
}, },
) { pad -> ) { pad ->
Column( LazyColumn(
contentPadding = FeedPadding,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding( .padding(
16.dp, start = 10.dp,
pad.calculateTopPadding(), end = 10.dp,
16.dp, top = pad.calculateTopPadding(),
pad.calculateBottomPadding(), bottom = pad.calculateBottomPadding(),
), ).consumeWindowInsets(pad),
verticalArrangement = Arrangement.SpaceAround,
) { ) {
LazyColumn(contentPadding = FeedPadding) { item {
SettingsCategory(
stringRes(R.string.public_home_section),
stringRes(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer),
action = {
ResetDMRelays(dmViewModel)
},
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_outbox_section),
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.search_section),
stringRes(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.local_section),
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.kind_3_section),
stringRes(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item { item {
SettingsCategory( SettingsCategory(
stringRes(R.string.public_home_section), stringRes(R.string.kind_3_recommended_section),
stringRes(R.string.public_home_section_explainer), stringRes(R.string.kind_3_recommended_section_description),
Modifier.padding(bottom = 8.dp),
) )
} }
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav) renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_outbox_section),
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.search_section),
stringRes(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.local_section),
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.kind_3_section),
stringRes(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item {
SettingsCategory(
stringRes(R.string.kind_3_recommended_section),
stringRes(R.string.kind_3_recommended_section_description),
)
}
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
}
} }
} }
} }
@@ -280,7 +284,20 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
postViewModel.deleteAll() postViewModel.deleteAll()
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) } DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays))
}
}
@Composable
fun ResetDMRelays(postViewModel: DMRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments() postViewModel.loadRelayDocuments()
}, },
) { ) {
@@ -143,7 +143,7 @@ class Kind3RelayListViewModel : ViewModel() {
val proposed = val proposed =
RelayListRecommendationProcessor RelayListRecommendationProcessor
.reliableRelaySetFor( .reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull { account.liveKind3Follows.value.authors.mapNotNull {
account.getNIP65RelayList(it) account.getNIP65RelayList(it)
}, },
relayUrlsToIgnore = relayUrlsToIgnore =
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableEmail import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
@@ -104,6 +105,7 @@ fun RelayInformationDialog(
dismissOnClickOutside = false, dismissOnClickOutside = false,
), ),
) { ) {
SetDialogToEdgeToEdge()
Surface { Surface {
val color = val color =
remember { remember {
@@ -67,7 +67,7 @@ open class DiscoverLiveFeedFilter(
override fun sort(collection: Set<Note>): List<Note> { override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = val followingKeySet =
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors
val counter = ParticipantListBuilder() val counter = ParticipantListBuilder()
val participantCounts = val participantCounts =
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class FilterByListParams( class FilterByListParams(
val isGlobal: Boolean, val isGlobal: Boolean,
val isHiddenList: Boolean, val isHiddenList: Boolean,
val followLists: Account.LiveFollowLists?, val followLists: Account.LiveFollowList?,
val hiddenLists: Account.LiveHiddenUsers, val hiddenLists: Account.LiveHiddenUsers,
val now: Long = TimeUtils.oneMinuteFromNow(), val now: Long = TimeUtils.oneMinuteFromNow(),
) { ) {
@@ -45,22 +45,22 @@ class FilterByListParams(
if (followLists == null) return false if (followLists == null) return false
return if (noteEvent is LiveActivitiesEvent) { return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(followLists.users) || noteEvent.participantsIntersect(followLists.authors) ||
noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) || noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities) noteEvent.isTaggedAddressableNotes(followLists.addresses)
} else { } else {
noteEvent.pubKey in followLists.users || noteEvent.pubKey in followLists.authors ||
noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) || noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities) noteEvent.isTaggedAddressableNotes(followLists.addresses)
} }
} }
fun isATagInList(aTag: ATag): Boolean { fun isATagInList(aTag: ATag): Boolean {
if (followLists == null) return false if (followLists == null) return false
return aTag.pubKeyHex in followLists.users return aTag.pubKeyHex in followLists.authors
} }
fun match( fun match(
@@ -89,7 +89,7 @@ class FilterByListParams(
fun create( fun create(
userHex: String, userHex: String,
selectedListName: String, selectedListName: String,
followLists: Account.LiveFollowLists?, followLists: Account.LiveFollowList?,
hiddenUsers: Account.LiveHiddenUsers, hiddenUsers: Account.LiveHiddenUsers,
): FilterByListParams = ): FilterByListParams =
FilterByListParams( FilterByListParams(
@@ -64,6 +64,7 @@ class GeoHashFeedFilter(
it.event is AudioHeaderEvent it.event is AudioHeaderEvent
) && ) &&
it.event?.isTaggedGeoHash(geoTag) == true && it.event?.isTaggedGeoHash(geoTag) == true &&
!it.isHiddenFor(account.flowHiddenUsers.value) &&
account.isAcceptable(it) account.isAcceptable(it)
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder) override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
@@ -68,6 +68,7 @@ class HashtagFeedFilter(
it.event is AudioHeaderEvent it.event is AudioHeaderEvent
) && ) &&
it.event?.isTaggedHash(hashTag) == true && it.event?.isTaggedHash(hashTag) == true &&
!it.isHiddenFor(account.flowHiddenUsers.value) &&
account.isAcceptable(it) account.isAcceptable(it)
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder) override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
@@ -115,7 +115,7 @@ class NotificationFeedFilter(
it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is NIP90ContentDiscoveryRequestEvent &&
it.event !is GiftWrapEvent && it.event !is GiftWrapEvent &&
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) && (filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false && it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
tagsAnEventByUser(it, loggedInUserHex) tagsAnEventByUser(it, loggedInUserHex)
@@ -37,7 +37,7 @@ class ThreadFeedFilter(
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf() val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.liveKind3Follows.value.users val followingKeySet = account.liveKind3Follows.value.authors
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
val eventsInHex = eventsToWatch.map { it.idHex }.toSet() val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
val now = TimeUtils.now() val now = TimeUtils.now()
@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.CodeName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.FollowListState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -112,7 +112,7 @@ private fun LoggedInUserPictureDrawer(
fun FollowListWithRoutes( fun FollowListWithRoutes(
followListsModel: FollowListState, followListsModel: FollowListState,
listName: String, listName: String,
onChange: (CodeName) -> Unit, onChange: (FeedDefinition) -> Unit,
) { ) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
@@ -128,7 +128,7 @@ fun FollowListWithRoutes(
fun FollowListWithoutRoutes( fun FollowListWithoutRoutes(
followListsModel: FollowListState, followListsModel: FollowListState,
listName: String, listName: String,
onChange: (CodeName) -> Unit, onChange: (FeedDefinition) -> Unit,
) { ) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
@@ -394,7 +394,7 @@ private fun FollowingAndFollowerCounts(
) { ) {
Text( Text(
text = text =
followingCount.value.users.size followingCount.value.authors.size
.toString(), .toString(),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -20,10 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.Manifest
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -33,6 +35,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,12 +45,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map import androidx.lifecycle.map
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CodeName import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.GeoHashName
import com.vitorpamplona.amethyst.ui.screen.HashtagName import com.vitorpamplona.amethyst.ui.screen.HashtagName
import com.vitorpamplona.amethyst.ui.screen.Name import com.vitorpamplona.amethyst.ui.screen.Name
@@ -55,15 +67,18 @@ import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun FeedFilterSpinner( fun FeedFilterSpinner(
placeholderCode: String, placeholderCode: String,
explainer: String, explainer: String,
options: ImmutableList<CodeName>, options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -75,20 +90,61 @@ fun FeedFilterSpinner(
id = R.string.select_an_option, id = R.string.select_an_option,
) )
var currentText by var selected by
remember(placeholderCode, options) { remember(placeholderCode, options) {
mutableStateOf( mutableStateOf(
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption, options.firstOrNull { it.code == placeholderCode },
) )
} }
val currentText by
remember(placeholderCode, options) {
derivedStateOf {
selected?.name?.name(context) ?: selectAnOption
}
}
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier) Spacer(modifier = Size20Modifier)
Text(currentText)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(currentText)
if (selected is AroundMeFeedDefinition) {
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle(null)
location?.let {
LoadCityName(
geohashStr = it,
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
}
}
}
Icon( Icon(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = explainer, contentDescription = explainer,
@@ -115,7 +171,7 @@ fun FeedFilterSpinner(
options = options, options = options,
onDismiss = { optionsShowing = false }, onDismiss = { optionsShowing = false },
onSelect = { onSelect = {
currentText = options[it].name.name(context) selected = options[it]
optionsShowing = false optionsShowing = false
onSelect(it) onSelect(it)
}, },
@@ -671,13 +671,13 @@ fun LoadModerators(
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
@@ -724,7 +724,7 @@ private fun LoadParticipants(
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder() ParticipantListBuilder()
@@ -733,7 +733,7 @@ private fun LoadParticipants(
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder() ParticipantListBuilder()
.followsThatParticipateOn(baseNote, allFollows) .followsThatParticipateOn(baseNote, allFollows)
@@ -882,7 +882,7 @@ fun RenderChannelThumb(
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder() ParticipantListBuilder()
.followsThatParticipateOn(baseNote, followingKeySet) .followsThatParticipateOn(baseNote, followingKeySet)
@@ -890,7 +890,7 @@ fun RenderChannelThumb(
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -42,7 +44,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -59,7 +60,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -375,16 +375,7 @@ private fun RenderOptionAfterVote(
QuoteBorder, QuoteBorder,
), ),
) { ) {
LinearProgressIndicator( DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
modifier = Modifier.matchParentSize(),
color = color,
gapSize = 0.dp,
strokeCap = StrokeCap.Square,
drawStopIndicator = {},
progress = {
poolOption.tally.value.toFloat()
},
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -398,10 +389,7 @@ private fun RenderOptionAfterVote(
.width(45.dp) .width(45.dp)
}, },
) { ) {
Text( TallyText(poolOption)
text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold,
)
} }
Column( Column(
@@ -429,6 +417,43 @@ private fun RenderOptionAfterVote(
} }
} }
@Composable
fun DisplayProgress(
poolOption: PollOption,
color: Color,
modifier: Modifier,
) {
val progress by poolOption.tally
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
Box(modifier = modifier) {
Box(
modifier =
Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(color = color),
) {
}
}
}
@Composable
private fun TallyText(poolOption: PollOption) {
val state = poolOption.tally
val progressTxt by
remember {
derivedStateOf {
"${(state.value * 100).roundToInt()}%"
}
}
Text(
text = progressTxt,
fontWeight = FontWeight.Bold,
)
}
@Composable @Composable
private fun RenderOptionBeforeVote( private fun RenderOptionBeforeVote(
baseNote: Note, baseNote: Note,
@@ -46,7 +46,7 @@ data class PollOption(
val option: Int, val option: Int,
val descriptor: String, val descriptor: String,
var zappedValue: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO), var zappedValue: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
var tally: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO), var tally: MutableState<Float> = mutableStateOf(0f),
var consensusThreadhold: MutableState<Boolean> = mutableStateOf(false), var consensusThreadhold: MutableState<Boolean> = mutableStateOf(false),
var zappedByLoggedIn: MutableState<Boolean> = mutableStateOf(false), var zappedByLoggedIn: MutableState<Boolean> = mutableStateOf(false),
) )
@@ -126,7 +126,7 @@ class PollNoteViewModel : ViewModel() {
} }
it.zappedValue.value = zappedValue it.zappedValue.value = zappedValue
it.tally.value = tallyValue it.tally.value = tallyValue.toFloat()
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
} }
@@ -261,7 +261,7 @@ fun UpdateZapAmountDialog(
) { ) {
Column { Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -373,6 +373,6 @@ fun WatchUserFollows(
} else { } else {
val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
onFollowChanges(state.users.contains(userHex)) onFollowChanges(state.authors.contains(userHex))
} }
} }
@@ -144,12 +144,6 @@ fun AddInboxRelayForDMCard(
Spacer(modifier = StdVertSpacer) Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(id = R.string.dm_relays_not_found_examples),
)
Spacer(modifier = StdVertSpacer)
var wantsToEditRelays by remember { mutableStateOf(false) } var wantsToEditRelays by remember { mutableStateOf(false) }
if (wantsToEditRelays) { if (wantsToEditRelays) {
AddDMRelayListDialog({ wantsToEditRelays = false }, accountViewModel, nav = nav) AddDMRelayListDialog({ wantsToEditRelays = false }, accountViewModel, nav = nav)
@@ -355,5 +355,5 @@ fun WatchAddressableNoteFollows(
) { ) {
val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
onFollowChanges(state.communities.contains(note.idHex)) onFollowChanges(state.addresses.contains(note.idHex))
} }
@@ -21,7 +21,11 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import android.util.Log import android.util.Log
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -280,6 +284,8 @@ abstract class FeedViewModel(
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
var llState: LazyListState by mutableStateOf(LazyListState(0, 0))
private var collectorJob: Job? = null private var collectorJob: Job? = null
init { init {
@@ -25,6 +25,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
@@ -33,10 +34,24 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -56,33 +71,56 @@ class FollowListState(
val viewModelScope: CoroutineScope, val viewModelScope: CoroutineScope,
) { ) {
val kind3Follow = val kind3Follow =
CodeName( PeopleListOutBoxFeedDefinition(
KIND3_FOLLOWS, code = KIND3_FOLLOWS,
ResourceName(R.string.follow_list_kind3follows), name = ResourceName(R.string.follow_list_kind3follows),
CodeNameType.HARDCODED, type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)),
) )
val globalFollow =
CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED)
val muteListFollow =
CodeName(
MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
ResourceName(R.string.follow_list_mute_list),
CodeNameType.HARDCODED,
)
val defaultLists = persistentListOf(kind3Follow, globalFollow, muteListFollow)
fun getPeopleLists(): List<CodeName> = val globalFollow =
GlobalFeedDefinition(
code = GLOBAL_FOLLOWS,
name = ResourceName(R.string.follow_list_global),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
relays = account.activeGlobalRelays().toList(),
)
val aroundMe =
AroundMeFeedDefinition(
code = AROUND_ME,
name = ResourceName(R.string.follow_list_aroundme),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
)
val muteListFollow =
PeopleListOutBoxFeedDefinition(
code = MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
name = ResourceName(R.string.follow_list_mute_list),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)),
)
val defaultLists = persistentListOf(kind3Follow, globalFollow, aroundMe, muteListFollow)
fun getPeopleLists(): List<FeedDefinition> =
account account
.getAllPeopleLists() .getAllPeopleLists()
.map { .map {
CodeName( PeopleListOutBoxFeedDefinition(
it.idHex, it.idHex,
PeopleListName(it), PeopleListName(it),
CodeNameType.PEOPLE_LIST, CodeNameType.PEOPLE_LIST,
kinds = DEFAULT_FEED_KINDS,
listOf(it.idHex),
) )
}.sortedBy { it.name.name() } }.sortedBy { it.name.name() }
val livePeopleListsFlow = MutableStateFlow(emptyList<CodeName>()) val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>())
fun updateFeedWith(newNotes: Set<Note>) { fun updateFeedWith(newNotes: Set<Note>) {
checkNotInMainThread() checkNotInMainThread()
@@ -118,29 +156,46 @@ class FollowListState(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<List<CodeName>> = val liveKind3FollowsFlow: Flow<List<FeedDefinition>> =
account.liveKind3Follows.transformLatest { account.liveKind3Follows.transformLatest {
checkNotInMainThread() checkNotInMainThread()
val communities = val communities =
it.communities.mapNotNull { it.addresses.mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName( TagFeedDefinition(
"Community/${communityNote.idHex}", "Community/${communityNote.idHex}",
CommunityName(communityNote), CommunityName(communityNote),
CodeNameType.ROUTE, CodeNameType.ROUTE,
kinds = DEFAULT_COMMUNITY_FEEDS,
aTags = listOf(communityNote.idHex),
relays = account.activeGlobalRelays().toList(),
) )
} }
} }
val hashtags = val hashtags =
it.hashtags.map { it.hashtags.map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) TagFeedDefinition(
"Hashtag/$it",
HashtagName(it),
CodeNameType.ROUTE,
kinds = DEFAULT_FEED_KINDS,
tTags = listOf(it),
relays = account.activeGlobalRelays().toList(),
)
} }
val geotags = val geotags =
it.geotags.map { it.geotags.map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) TagFeedDefinition(
"Geohash/$it",
GeoHashName(it),
CodeNameType.ROUTE,
kinds = DEFAULT_FEED_KINDS,
gTags = listOf(it),
relays = account.activeGlobalRelays().toList(),
)
} }
emit( emit(
@@ -156,7 +211,7 @@ class FollowListState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(kind3Follow, globalFollow), listOf(kind3Follow, aroundMe, globalFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
myLiveKind3FollowsFlow, myLiveKind3FollowsFlow,
listOf(muteListFollow), listOf(muteListFollow),
@@ -172,7 +227,7 @@ class FollowListState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(kind3Follow, globalFollow), listOf(kind3Follow, aroundMe, globalFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
listOf(muteListFollow), listOf(muteListFollow),
).flatten().toImmutableList(), ).flatten().toImmutableList(),
@@ -238,8 +293,78 @@ class CommunityName(
} }
@Immutable @Immutable
data class CodeName( abstract class FeedDefinition(
val code: String, val code: String,
val name: Name, val name: Name,
val type: CodeNameType, val type: CodeNameType,
) )
@Immutable
class GlobalFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val relays: List<String>,
) : FeedDefinition(code, name, type)
@Immutable
class TagFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val relays: List<String>,
val pTags: List<String>? = null,
val eTags: List<String>? = null,
val aTags: List<String>? = null,
val tTags: List<String>? = null,
val gTags: List<String>? = null,
) : FeedDefinition(code, name, type)
@Immutable
class AroundMeFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
) : FeedDefinition(code, name, type)
@Immutable
class PeopleListOutBoxFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val unpackList: List<String>,
) : FeedDefinition(code, name, type)
val DEFAULT_FEED_KINDS =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
)
val DEFAULT_COMMUNITY_FEEDS =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
)
@@ -120,7 +120,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -396,7 +395,7 @@ class AccountViewModel(
note.flow().metadata.stateFlow, note.flow().metadata.stateFlow,
note.flow().reports.stateFlow, note.flow().reports.stateFlow,
) { hiddenUsers, followingUsers, autor, metadata, reports -> ) { hiddenUsers, followingUsers, autor, metadata, reports ->
emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users)) emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors))
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
.stateIn( .stateIn(
viewModelScope, viewModelScope,
@@ -188,7 +188,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -1335,21 +1334,9 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
@Composable @Composable
fun DisplayLocationObserver(postViewModel: NewPostViewModel) { fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
val context = LocalContext.current val location by postViewModel.locationFlow().collectAsStateWithLifecycle(null)
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
DisposableEffect(key1 = context) { location?.let { DisplayLocationInTitle(geohash = it) }
postViewModel.startLocation(context = context)
locationDescriptionFlow = postViewModel.location
onDispose { postViewModel.stopLocation() }
}
locationDescriptionFlow?.let {
val location by it.collectAsStateWithLifecycle(null)
location?.let { DisplayLocationInTitle(geohash = it) }
}
} }
@Composable @Composable
@@ -75,7 +75,7 @@ class SearchBarViewModel(
_hashtagResults.emit(findHashtags(searchValue)) _hashtagResults.emit(findHashtags(searchValue))
_searchResultsUsers.emit( _searchResultsUsers.emit(
LocalCache LocalCache
.findUsersStartingWith(searchValue) .findUsersStartingWith(searchValue, account)
.sortedWith( .sortedWith(
compareBy( compareBy(
{ it.toBestDisplayName().startsWith(searchValue, true) }, { it.toBestDisplayName().startsWith(searchValue, true) },
@@ -86,7 +86,7 @@ class SearchBarViewModel(
) )
_searchResultsNotes.emit( _searchResultsNotes.emit(
LocalCache LocalCache
.findNotesStartingWith(searchValue) .findNotesStartingWith(searchValue, account)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed(), .reversed(),
) )
@@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -178,7 +177,7 @@ fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) {
} }
} }
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) @OptIn(FlowPreview::class)
@Composable @Composable
private fun SearchBar( private fun SearchBar(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
@@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -197,7 +196,6 @@ import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -211,17 +209,15 @@ fun ThreadFeedView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val listState = rememberLazyListState()
RefresheableBox(viewModel) { RefresheableBox(viewModel) {
RenderFeedState( RenderFeedState(
viewModel = viewModel, viewModel = viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
listState = listState, listState = viewModel.llState,
nav = nav, nav = nav,
routeForLastRead = null, routeForLastRead = null,
onLoaded = { onLoaded = {
RenderThreadFeed(noteId, it, listState, accountViewModel, nav) RenderThreadFeed(noteId, it, viewModel.llState, accountViewModel, nav)
}, },
) )
} }
@@ -236,7 +232,6 @@ fun RenderThreadFeed(
nav: INav, nav: INav,
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val firstTimeScrolled = remember { TimeUtils.now() }
LaunchedEffect(noteId, items.list) { LaunchedEffect(noteId, items.list) {
// hack to allow multiple scrolls to Item while posts on the screen load. // hack to allow multiple scrolls to Item while posts on the screen load.
@@ -252,10 +247,15 @@ fun RenderThreadFeed(
// records before setting up the position on the feed. // records before setting up the position on the feed.
// //
// It jumps around, but it is the best we can do. // It jumps around, but it is the best we can do.
if (TimeUtils.now() - firstTimeScrolled < 1000) { if (listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 && items.list.size > 3) {
val position = items.list.indexOfFirst { it.idHex == noteId } val position = items.list.indexOfFirst { it.idHex == noteId }
if (position >= 0) { if (position >= 0) {
listState.scrollToItem(position, -200) if (position > items.list.size - 3) {
listState.scrollToItem(position, 0)
} else {
listState.scrollToItem(position, -200)
}
} }
} }
} }
+196
View File
@@ -3,18 +3,23 @@
<string name="point_to_the_qr_code">کد QR را نشانه بگیرید</string> <string name="point_to_the_qr_code">کد QR را نشانه بگیرید</string>
<string name="show_qr">کد QR را نشان بده</string> <string name="show_qr">کد QR را نشان بده</string>
<string name="profile_image">تصویر نمایه</string> <string name="profile_image">تصویر نمایه</string>
<string name="your_profile_image">تصویر نمایه شما</string>
<string name="scan_qr">اسکن کد QR</string> <string name="scan_qr">اسکن کد QR</string>
<string name="show_anyway">بهر حال نشان بده</string> <string name="show_anyway">بهر حال نشان بده</string>
<string name="post_was_hidden">این پست پنهان شده چون به کلمات یا کاربران پنهان شده شما اشاره دارد</string>
<string name="post_was_flagged_as_inappropriate_by">این یادداشت توسط این افراد گزارش شده:</string> <string name="post_was_flagged_as_inappropriate_by">این یادداشت توسط این افراد گزارش شده:</string>
<string name="post_not_found">این یادداشت یافت نشد</string> <string name="post_not_found">این یادداشت یافت نشد</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">تصویر کانال</string> <string name="channel_image">تصویر کانال</string>
<string name="referenced_event_not_found">رویداد مورد نظر یافت نشد</string> <string name="referenced_event_not_found">رویداد مورد نظر یافت نشد</string>
<string name="could_not_decrypt_the_message">پیام رمزگشایی نشد</string> <string name="could_not_decrypt_the_message">پیام رمزگشایی نشد</string>
<string name="group_picture">تصویر گروه</string> <string name="group_picture">تصویر گروه</string>
<string name="explicit_content">محتوای نامناسب</string> <string name="explicit_content">محتوای نامناسب</string>
<string name="spam">اسپم</string> <string name="spam">اسپم</string>
<string name="spam_description">تعداد رویدادهای اسپم که از این رله می آید</string>
<string name="impersonation">جعل هویت</string> <string name="impersonation">جعل هویت</string>
<string name="illegal_behavior">رفتار ناهنجار</string> <string name="illegal_behavior">رفتار ناهنجار</string>
<string name="other">سایر</string>
<string name="unknown">ناشناس</string> <string name="unknown">ناشناس</string>
<string name="relay_icon">آیکن رله</string> <string name="relay_icon">آیکن رله</string>
<string name="unknown_author">نویسنده ناشناس</string> <string name="unknown_author">نویسنده ناشناس</string>
@@ -22,6 +27,9 @@
<string name="copy_user_pubkey">کپی شناسه نویسنده</string> <string name="copy_user_pubkey">کپی شناسه نویسنده</string>
<string name="copy_note_id">کپی شناسه یادداشت</string> <string name="copy_note_id">کپی شناسه یادداشت</string>
<string name="broadcast">انتشار</string> <string name="broadcast">انتشار</string>
<string name="timestamp_it">مهر زمان و تاریخ بزن</string>
<string name="timestamp_pending">مهر زمان: در انتظار تایید</string>
<string name="timestamp_pending_short">OTS: در حال انتظار</string>
<string name="request_deletion">درخواست حذف/string&gt;</string> <string name="request_deletion">درخواست حذف/string&gt;</string>
<string name="block_report">مسدود/گزارش</string> <string name="block_report">مسدود/گزارش</string>
<string name="block_hide_user"><![CDATA[بلاک/پنهان کردن کاربر]]></string> <string name="block_hide_user"><![CDATA[بلاک/پنهان کردن کاربر]]></string>
@@ -29,6 +37,10 @@
<string name="report_impersonation">گزارش جعل هویت</string> <string name="report_impersonation">گزارش جعل هویت</string>
<string name="report_explicit_content">گزارش محتوای نامناسب</string> <string name="report_explicit_content">گزارش محتوای نامناسب</string>
<string name="report_illegal_behaviour">گزارش رفتار ناهنجار</string> <string name="report_illegal_behaviour">گزارش رفتار ناهنجار</string>
<string name="report_malware">گزارش بدافزار</string>
<string name="report_mod">گزارش مود</string>
<string name="malware">بدافزار</string>
<string name="mod">مود</string>
<string name="login_with_a_private_key_to_be_able_to_reply">با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید</string> <string name="login_with_a_private_key_to_be_able_to_reply">با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">با کلید خصوصی وارد شوید تا بتوانید یادداشت را بازنشر کنید</string> <string name="login_with_a_private_key_to_be_able_to_boost_posts">با کلید خصوصی وارد شوید تا بتوانید یادداشت را بازنشر کنید</string>
<string name="login_with_a_private_key_to_like_posts">با کلید خصوصی وارد شوید تا بتوانید یادداشت را لایک کنید</string> <string name="login_with_a_private_key_to_like_posts">با کلید خصوصی وارد شوید تا بتوانید یادداشت را لایک کنید</string>
@@ -42,7 +54,12 @@
<string name="view_count">دیدن تعداد</string> <string name="view_count">دیدن تعداد</string>
<string name="boost">بازنشر</string> <string name="boost">بازنشر</string>
<string name="boosted">بازنشر شده</string> <string name="boosted">بازنشر شده</string>
<string name="edited">ویرایش شده</string>
<string name="edited_number">ویرایش#%1$s</string>
<string name="original">اورجینال</string>
<string name="quote">نقل قول</string> <string name="quote">نقل قول</string>
<string name="fork">فورک</string>
<string name="propose_an_edit">پیشنهاد ویرایش</string>
<string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string> <string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string>
<string name="add">افزودن</string> <string name="add">افزودن</string>
<string name="replying_to">" پاسخ به"</string> <string name="replying_to">" پاسخ به"</string>
@@ -82,6 +99,7 @@
<string name="posts">یادداشت ها</string> <string name="posts">یادداشت ها</string>
<string name="bytes">بایت</string> <string name="bytes">بایت</string>
<string name="errors">خطاها</string> <string name="errors">خطاها</string>
<string name="errors_description">تعداد خطاهای اتصال در این نوبت</string>
<string name="home_feed">خبرنامه اصلی</string> <string name="home_feed">خبرنامه اصلی</string>
<string name="private_message_feed">پیام خصوصی</string> <string name="private_message_feed">پیام خصوصی</string>
<string name="public_chat_feed">گفتگوی عمومی</string> <string name="public_chat_feed">گفتگوی عمومی</string>
@@ -100,8 +118,11 @@
<string name="website_url">آدری وبسایت</string> <string name="website_url">آدری وبسایت</string>
<string name="ln_address">آدرس لایتنینگ</string> <string name="ln_address">آدرس لایتنینگ</string>
<string name="ln_url_outdated">آدرس لایتنینگ(نقل شده)</string> <string name="ln_url_outdated">آدرس لایتنینگ(نقل شده)</string>
<string name="save_to_gallery">در گالری ذخیره کن</string>
<string name="image_saved_to_the_gallery">تصویر در گالری ذخیره شد</string> <string name="image_saved_to_the_gallery">تصویر در گالری ذخیره شد</string>
<string name="failed_to_save_the_image">تصویر ذخیره نشد</string> <string name="failed_to_save_the_image">تصویر ذخیره نشد</string>
<string name="video_saved_to_the_gallery">ویدئو در گالری گوشی ذخیره شد</string>
<string name="failed_to_save_the_video">ویدئو ذخیره نشد</string>
<string name="upload_image">بارگذاری تصویر</string> <string name="upload_image">بارگذاری تصویر</string>
<string name="uploading">...در حال بارگذاری</string> <string name="uploading">...در حال بارگذاری</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
@@ -117,6 +138,7 @@
<string name="conversations">گفتگوها</string> <string name="conversations">گفتگوها</string>
<string name="notes">یادداشت ها</string> <string name="notes">یادداشت ها</string>
<string name="replies">پاسخ ها</string> <string name="replies">پاسخ ها</string>
<string name="gallery">گالری</string>
<string name="follows">"دنبال شوندگان"</string> <string name="follows">"دنبال شوندگان"</string>
<string name="reports">"گزارش ها"</string> <string name="reports">"گزارش ها"</string>
<string name="more_options">انتخاب های بیشتر</string> <string name="more_options">انتخاب های بیشتر</string>
@@ -138,12 +160,15 @@
<string name="clear">پاک کردن</string> <string name="clear">پاک کردن</string>
<string name="app_logo">لوگوی اپ</string> <string name="app_logo">لوگوی اپ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string>
<string name="ncryptsec_password">پسورد باز کردن کلید</string>
<string name="show_password">نشان دادن گذرواژه</string> <string name="show_password">نشان دادن گذرواژه</string>
<string name="hide_password">پنهان کردن گذرواژه</string> <string name="hide_password">پنهان کردن گذرواژه</string>
<string name="invalid_key">کلید نامعتبر</string> <string name="invalid_key">کلید نامعتبر</string>
<string name="invalid_key_with_message">کلید نامعتبر: %1$s</string>
<string name="i_accept_the">"می پذیرم "</string> <string name="i_accept_the">"می پذیرم "</string>
<string name="terms_of_use">شرایط استفاده</string> <string name="terms_of_use">شرایط استفاده</string>
<string name="acceptance_of_terms_is_required">پذیرش شرایط الازمیست</string> <string name="acceptance_of_terms_is_required">پذیرش شرایط الازمیست</string>
<string name="password_is_required">رمز عبور لازم است</string>
<string name="key_is_required">کلید الزامیست</string> <string name="key_is_required">کلید الزامیست</string>
<string name="name_is_required">نام الزامی است</string> <string name="name_is_required">نام الزامی است</string>
<string name="login">ورود</string> <string name="login">ورود</string>
@@ -194,8 +219,18 @@
<string name="mark_all_new_as_read">همه جدیدها را خوانده شده کن</string> <string name="mark_all_new_as_read">همه جدیدها را خوانده شده کن</string>
<string name="mark_all_as_read">همه را خوانده شده کن</string> <string name="mark_all_as_read">همه را خوانده شده کن</string>
<string name="backup_keys">بازیابی کلیدها</string> <string name="backup_keys">بازیابی کلیدها</string>
<string name="account_backup_tips2_md" tools:ignore="Typos">نکات ایمنی و ذخیره کلید
حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد.
هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**.
سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند.
یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم.</string>
<string name="account_backup_tips3_md" tools:ignore="Typos">برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود.
اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید.</string>
<string name="failed_to_encrypt_key">کلید خصوصی شما رمزنگاری نشد</string>
<string name="secret_key_copied_to_clipboard">کلید خصوصی در کلیپبورد کپی شد</string> <string name="secret_key_copied_to_clipboard">کلید خصوصی در کلیپبورد کپی شد</string>
<string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string> <string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string>
<string name="encrypt_and_copy_my_secret_key">رمزنگاری و کپی کلید من</string>
<string name="biometric_authentication_failed">احراز هویت انجام نشد</string> <string name="biometric_authentication_failed">احراز هویت انجام نشد</string>
<string name="biometric_authentication_failed_explainer">مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند</string> <string name="biometric_authentication_failed_explainer">مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند</string>
<string name="biometric_authentication_failed_explainer_with_error">مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند. خطا: %1$s</string> <string name="biometric_authentication_failed_explainer_with_error">مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند. خطا: %1$s</string>
@@ -226,6 +261,8 @@
<string name="quick_action_delete">حذف</string> <string name="quick_action_delete">حذف</string>
<string name="quick_action_unfollow">دنبال نکردن</string> <string name="quick_action_unfollow">دنبال نکردن</string>
<string name="quick_action_follow">دنبال کردن</string> <string name="quick_action_follow">دنبال کردن</string>
<string name="quick_action_request_deletion_gallery_title">حذف از گالری</string>
<string name="quick_action_request_deletion_gallery_alert_body">حذف این رسانه از گالری، بعدا می توانید آن را بخوانید</string>
<string name="quick_action_request_deletion_alert_title">درخواست حذف</string> <string name="quick_action_request_deletion_alert_title">درخواست حذف</string>
<string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string> <string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string>
<string name="quick_action_block_dialog_btn">بلاک</string> <string name="quick_action_block_dialog_btn">بلاک</string>
@@ -239,6 +276,7 @@
<string name="report_dialog_impersonation">جعل هویت بدخواهانه</string> <string name="report_dialog_impersonation">جعل هویت بدخواهانه</string>
<string name="report_dialog_nudity">محتوای برهنگی یا نمایان</string> <string name="report_dialog_nudity">محتوای برهنگی یا نمایان</string>
<string name="report_dialog_illegal">رفتار ناهنجار</string> <string name="report_dialog_illegal">رفتار ناهنجار</string>
<string name="report_dialog_malware">نرم‌افزار خرابکار</string>
<string name="report_dialog_blocking_a_user">بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. یادداشت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند.</string> <string name="report_dialog_blocking_a_user">بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. یادداشت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[مسدود و پنهان کردن کاربر]]></string> <string name="report_dialog_block_hide_user_btn"><![CDATA[مسدود و پنهان کردن کاربر]]></string>
<string name="report_dialog_report_btn">گزارش سواستفاده</string> <string name="report_dialog_report_btn">گزارش سواستفاده</string>
@@ -251,6 +289,7 @@
<string name="report_dialog_title">بلاک و گزارش</string> <string name="report_dialog_title">بلاک و گزارش</string>
<string name="block_only">بلاک</string> <string name="block_only">بلاک</string>
<string name="bookmarks">علامت گذاشته ها</string> <string name="bookmarks">علامت گذاشته ها</string>
<string name="drafts">پیش‌نویس‌</string>
<string name="private_bookmarks">علامت گاشته های خصوصی</string> <string name="private_bookmarks">علامت گاشته های خصوصی</string>
<string name="public_bookmarks">علامت گذاشته های عمومی</string> <string name="public_bookmarks">علامت گذاشته های عمومی</string>
<string name="add_to_private_bookmarks">افزودن به علامت گذاشته های خصوصی</string> <string name="add_to_private_bookmarks">افزودن به علامت گذاشته های خصوصی</string>
@@ -275,6 +314,7 @@
<string name="poll_zap_value_min">حداقل زپ</string> <string name="poll_zap_value_min">حداقل زپ</string>
<string name="poll_zap_value_max">حداکثر زپ</string> <string name="poll_zap_value_max">حداکثر زپ</string>
<string name="poll_consensus_threshold">توافق نظر</string> <string name="poll_consensus_threshold">توافق نظر</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">بسته شدن پس از</string> <string name="poll_closing_time">بسته شدن پس از</string>
<string name="poll_closing_time_days">روز</string> <string name="poll_closing_time_days">روز</string>
<string name="poll_unable_to_vote">نمی توان رای داد</string> <string name="poll_unable_to_vote">نمی توان رای داد</string>
@@ -309,18 +349,31 @@
<string name="zap_type_nonzap_explainer">هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود</string> <string name="zap_type_nonzap_explainer">هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود</string>
<string name="file_server">سرور فایل</string> <string name="file_server">سرور فایل</string>
<string name="zap_forward_lnAddress">آدرس لایتنینگ یا @User</string> <string name="zap_forward_lnAddress">آدرس لایتنینگ یا @User</string>
<string name="media_servers">سرورهای رسانه</string>
<string name="set_preferred_media_servers">سرورهای مورد علاقه خود برای بارگزاری رسانه را انتخاب کنید.</string>
<string name="no_media_server_message">هیچ مجموعه سرور سفارشی ندارید. می توانید از لیست امتیست استفاده کنید، یا از لیست زیر اضافه کنید↓</string>
<string name="built_in_media_servers_title">سرورهای داخلی رسانه</string>
<string name="built_in_servers_description">لیست پیش فرض امتیست. می توانید تک به تک یا تمام لیست را اضافه کنید.</string>
<string name="use_default_servers">استفاده از لیست پیش فرض</string>
<string name="add_media_server">افزودن سرور رسانه</string>
<string name="delete_media_server">حذف سرور رسانه</string>
<string name="upload_server_relays_nip95">رله های شما (NIP-95)</string> <string name="upload_server_relays_nip95">رله های شما (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر </string> <string name="upload_server_relays_nip95_explainer">فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر </string>
<string name="privacy_options">گزینه های حریم خصوصی</string>
<string name="connect_via_tor_short">Tor/Orbot تنظیمات</string> <string name="connect_via_tor_short">Tor/Orbot تنظیمات</string>
<string name="connect_via_tor"> از طریق تنظیم Orbot متصل شوید</string> <string name="connect_via_tor"> از طریق تنظیم Orbot متصل شوید</string>
<string name="connect_via_tor1">تنظیم</string>
<string name="connect_via_tor2">تنظیمات تور</string>
<string name="do_you_really_want_to_disable_tor_title">قطع اتصال از Orbot/Tor?</string> <string name="do_you_really_want_to_disable_tor_title">قطع اتصال از Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">داده های شما بلافاصله در شبکه معمولی منتقل می شوند</string> <string name="do_you_really_want_to_disable_tor_text">داده های شما بلافاصله در شبکه معمولی منتقل می شوند</string>
<string name="yes">بله</string> <string name="yes">بله</string>
<string name="no">خیر</string> <string name="no">خیر</string>
<string name="follow_list_selection">لیست دنبال ها</string> <string name="follow_list_selection">لیست دنبال ها</string>
<string name="follow_list_kind3follows">همه دنبال ها</string> <string name="follow_list_kind3follows">همه دنبال ها</string>
<string name="follow_list_aroundme">اطراف من</string>
<string name="follow_list_global">همگانی</string> <string name="follow_list_global">همگانی</string>
<string name="follow_list_mute_list">لیست خموش</string> <string name="follow_list_mute_list">لیست خموش</string>
<string name="connect_through_your_orbot_setup_short">درگاه پیش فرض 9050 است</string>
<string name="connect_through_your_orbot_setup_markdown"> ## از طریق Tor با Orbot متصل شوید <string name="connect_through_your_orbot_setup_markdown"> ## از طریق Tor با Orbot متصل شوید
\n\n1. نصب کنید [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) \n\n1. نصب کنید [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. شروع Orbot \n2. شروع Orbot
@@ -330,6 +383,45 @@
\n6. دکمه Activate را بفشارید تا از Orbot به عنوان پراکسی استفده نمایید \n6. دکمه Activate را بفشارید تا از Orbot به عنوان پراکسی استفده نمایید
</string> </string>
<string name="orbot_socks_port">درگاه Orbot Socks</string> <string name="orbot_socks_port">درگاه Orbot Socks</string>
<string name="use_internal_tor">موتور فعال تور</string>
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
<string name="tor_preset">تنظیمات پیش فرض حریم خصوصی/ تور</string>
<string name="tor_preset_explainer">تغییر سریع تمام تنظیمات زیر</string>
<string name="tor_use_onion_address">رله ها / آدرس های Onion </string>
<string name="tor_use_onion_address_explainer">از تور برای هر آدرس اونیون استفاده کن</string>
<string name="tor_use_dm_relays">رله های پیام خصوصی</string>
<string name="tor_use_dm_relays_explainer">تور را برای ارسال و دریافت پیام خصوصی الزامی کن</string>
<string name="tor_use_new_relays">رله های غیر معتمد</string>
<string name="tor_use_new_relays_explainer">تور را برای رله های ارسال / دریافت الزامی کن</string>
<string name="tor_use_trusted_relays">رله های معتمد</string>
<string name="tor_use_trusted_relays_explainer">تور را برای تمام رله های لیست الزامی کن</string>
<string name="tor_use_profile_pictures">تصاویر نمایه</string>
<string name="tor_use_profile_pictures_explainer">تور را برای بارگیری تصاویر نمایه اجباری کن</string>
<string name="tor_use_url_previews">پیش نمایش URL</string>
<string name="tor_use_url_previews_explainer">تور را برای بارگیری پیش نمایش آدرس الزامی کن</string>
<string name="tor_use_images">تصاویر</string>
<string name="tor_use_images_explainer">تور را برای بارگیری تصاویر الزامی کن</string>
<string name="tor_use_videos">ویدئوها</string>
<string name="tor_use_videos_explainer">تور را برای بارگیری ویدئوها الزامی کن</string>
<string name="tor_use_money_operations">عملیات پولی</string>
<string name="tor_use_money_operations_explainer">تور را برای زپ، لایتنینگ و انتقال کَشو الزامی کن</string>
<string name="tor_use_nip05_verification">تایید آدرس ناستر</string>
<string name="tor_use_nip05_verification_explainer">تور را برای تایید آدرس های NIP-05 الزامی کن</string>
<string name="tor_use_nip96_uploads">بارگذاری رسانه</string>
<string name="tor_use_nip96_uploads_explainer">تور را برای بارگذاری محتوا الزامی کن</string>
<string name="tor_internal">داخلی</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">خاموش</string>
<string name="tor_when_needed">مقدماتی</string>
<string name="tor_default">پیش‌فرض</string>
<string name="tor_small_payloads">همه بجز رسانه</string>
<string name="tor_full_privacy">حریم خصوصی کامل</string>
<string name="tor_custom">سفارشی</string>
<string name="tor_when_needed_explainer">وقتی توسط سرور الزام شده از تور استفاده کن</string>
<string name="tor_default_explainer">IP خود را از رله های غریبه پنهان کن</string>
<string name="tor_small_payloads_explainer">IP خود را از همه چیز بجز تصاویر و ویدئوها پنهان کن</string>
<string name="tor_full_privacy_explainer">IP خود را در تمام ارتباطات پنهان کن</string>
<string name="tor_custom_explainer">خودت را بساز</string>
<string name="invalid_port_number">شماره پورت نامعتبر</string> <string name="invalid_port_number">شماره پورت نامعتبر</string>
<string name="use_orbot">استفاده از Orbot</string> <string name="use_orbot">استفاده از Orbot</string>
<string name="disconnect_from_your_orbot_setup">قطع اتصال Tor/Orbot</string> <string name="disconnect_from_your_orbot_setup">قطع اتصال Tor/Orbot</string>
@@ -363,6 +455,8 @@
<string name="sats_to_complete">زپ گیری تا %1$s. %2$s مانده تا هدف</string> <string name="sats_to_complete">زپ گیری تا %1$s. %2$s مانده تا هدف</string>
<string name="read_from_relay">خواندن از رله</string> <string name="read_from_relay">خواندن از رله</string>
<string name="write_to_relay">نوشتن به رله</string> <string name="write_to_relay">نوشتن به رله</string>
<string name="write_to_relay_description">مقدار بایتی که به این رله فرستاده شده، شامل فیلترها و رویدادها</string>
<string name="read_from_relay_description">مقدار بایتی که از این رله فرستاده شده، شامل فیلترها و رویدادها</string>
<string name="an_error_occurred_trying_to_get_relay_information">در تلاش برای گرفتن اطلاعات رله از %1$s خطایی رخ داد</string> <string name="an_error_occurred_trying_to_get_relay_information">در تلاش برای گرفتن اطلاعات رله از %1$s خطایی رخ داد</string>
<string name="owner">مالک</string> <string name="owner">مالک</string>
<string name="version">نگارش</string> <string name="version">نگارش</string>
@@ -376,6 +470,7 @@
<string name="languages">زبان‌ها</string> <string name="languages">زبان‌ها</string>
<string name="tags">برچسب‌ها</string> <string name="tags">برچسب‌ها</string>
<string name="posting_policy">سیاست ارسال محتوا</string> <string name="posting_policy">سیاست ارسال محتوا</string>
<string name="relay_error_messages">خطاها و هشدارهای از این رله</string>
<string name="message_length">طول پیام</string> <string name="message_length">طول پیام</string>
<string name="subscriptions">اشتراک</string> <string name="subscriptions">اشتراک</string>
<string name="filters">فیلترها</string> <string name="filters">فیلترها</string>
@@ -384,6 +479,7 @@
<string name="maximum_event_tags">حداکثر تعداد برچسب رویداد</string> <string name="maximum_event_tags">حداکثر تعداد برچسب رویداد</string>
<string name="content_length">طول محتوا</string> <string name="content_length">طول محتوا</string>
<string name="minimum_pow">حداقل PoW</string> <string name="minimum_pow">حداقل PoW</string>
<string name="auth">احراز هویت</string>
<string name="payment">پرداخت</string> <string name="payment">پرداخت</string>
<string name="cashu">توکن Cashu</string> <string name="cashu">توکن Cashu</string>
<string name="cashu_redeem">بازپرداخت</string> <string name="cashu_redeem">بازپرداخت</string>
@@ -401,6 +497,7 @@
<string name="are_you_sure_you_want_to_log_out">خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟</string> <string name="are_you_sure_you_want_to_log_out">خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟</string>
<string name="followed_tags">برچسب های دنبال شده</string> <string name="followed_tags">برچسب های دنبال شده</string>
<string name="relay_setup">رله ها</string> <string name="relay_setup">رله ها</string>
<string name="discover_content">اکتشاف یادداشت</string>
<string name="discover_marketplace">بازار</string> <string name="discover_marketplace">بازار</string>
<string name="discover_live">زنده</string> <string name="discover_live">زنده</string>
<string name="discover_community">انجمن</string> <string name="discover_community">انجمن</string>
@@ -410,14 +507,20 @@
<string name="community_no_descriptor">این انجمن هیچ توضیح و قوانینی ندارد. با مالک گروه برای افزودن آن صحبت کنید.</string> <string name="community_no_descriptor">این انجمن هیچ توضیح و قوانینی ندارد. با مالک گروه برای افزودن آن صحبت کنید.</string>
<string name="add_sensitive_content_label">محتوای حساس</string> <string name="add_sensitive_content_label">محتوای حساس</string>
<string name="add_sensitive_content_description">هشدار محتوای حساس پیش از نمایش محتوا اضافه می کند</string> <string name="add_sensitive_content_description">هشدار محتوای حساس پیش از نمایش محتوا اضافه می کند</string>
<string name="preferences">ترجیحات اپ</string>
<string name="settings">تنظیمات</string> <string name="settings">تنظیمات</string>
<string name="connectivity_type_always">همیشه</string> <string name="connectivity_type_always">همیشه</string>
<string name="connectivity_type_wifi_only">فقط Wifi</string> <string name="connectivity_type_wifi_only">فقط Wifi</string>
<string name="connectivity_type_unmetered_wifi_only">وای فای بی اندازه</string>
<string name="connectivity_type_never">هرگز</string> <string name="connectivity_type_never">هرگز</string>
<string name="ui_feature_set_type_complete">کامل</string>
<string name="ui_feature_set_type_simplified">ساده</string>
<string name="ui_feature_set_type_performance">عملکرد</string>
<string name="system">سیستم</string> <string name="system">سیستم</string>
<string name="light">روشن</string> <string name="light">روشن</string>
<string name="dark">تاریک</string> <string name="dark">تاریک</string>
<string name="application_preferences">ترجیحات اپلیکیشن</string> <string name="application_preferences">ترجیحات اپلیکیشن</string>
<string name="wallet_connect">اتصال کیف پول</string>
<string name="language">زبان</string> <string name="language">زبان</string>
<string name="theme">پوسته</string> <string name="theme">پوسته</string>
<string name="automatically_load_images_gifs">پیش نمایش تصویر</string> <string name="automatically_load_images_gifs">پیش نمایش تصویر</string>
@@ -425,6 +528,8 @@
<string name="automatically_show_url_preview">پیش نمایش URL</string> <string name="automatically_show_url_preview">پیش نمایش URL</string>
<string name="automatically_hide_nav_bars">مرور ژرف</string> <string name="automatically_hide_nav_bars">مرور ژرف</string>
<string name="automatically_hide_nav_bars_description">پنهان کردن نوار پیمایش هنگام مرور</string> <string name="automatically_hide_nav_bars_description">پنهان کردن نوار پیمایش هنگام مرور</string>
<string name="ui_style">حالت رابط کاربر</string>
<string name="ui_style_description">قیافه پست را انتخاب کن</string>
<string name="load_image">بارگیری تصویر</string> <string name="load_image">بارگیری تصویر</string>
<string name="spamming_users">اسپمر</string> <string name="spamming_users">اسپمر</string>
<string name="muted_button">بیصدا شده. برای لغو کلیک کنید</string> <string name="muted_button">بیصدا شده. برای لغو کلیک کنید</string>
@@ -435,6 +540,7 @@
<string name="nip05_checking">بررسی آدرس ناستر</string> <string name="nip05_checking">بررسی آدرس ناستر</string>
<string name="select_deselect_all">انتخاب یا لغو انتخاب همه</string> <string name="select_deselect_all">انتخاب یا لغو انتخاب همه</string>
<string name="default_relays">پیش‌فرض</string> <string name="default_relays">پیش‌فرض</string>
<string name="default_relays_longer">بازگشت به حالت پیش فرض</string>
<string name="select_a_relay_to_continue">برای ادامه یک رله انتخاب کنید</string> <string name="select_a_relay_to_continue">برای ادامه یک رله انتخاب کنید</string>
<string name="zap_forward_title">باز ارسال زپ ها به:</string> <string name="zap_forward_title">باز ارسال زپ ها به:</string>
<string name="zap_forward_explainer">کلاینت هایی که این قابلیت را دارند زپ ها را به جای شما به LNAddress یا نمایه کاربر زیر می فرستند</string> <string name="zap_forward_explainer">کلاینت هایی که این قابلیت را دارند زپ ها را به جای شما به LNAddress یا نمایه کاربر زیر می فرستند</string>
@@ -445,6 +551,7 @@
<string name="new_feature_nip17_might_not_be_available_description">برای فعال سازی این حالت لازم است اماتیست یک پیغام NIP-17 بفرستد (پیغام های GiftWrapped, Sealed Direct and Group). مطمئن شوید که گیرنده از کلاینتی سازگاز استفاده می کند.</string> <string name="new_feature_nip17_might_not_be_available_description">برای فعال سازی این حالت لازم است اماتیست یک پیغام NIP-17 بفرستد (پیغام های GiftWrapped, Sealed Direct and Group). مطمئن شوید که گیرنده از کلاینتی سازگاز استفاده می کند.</string>
<string name="new_feature_nip17_activate">فعال‌سازی</string> <string name="new_feature_nip17_activate">فعال‌سازی</string>
<string name="messages_create_public_chat">عمومی</string> <string name="messages_create_public_chat">عمومی</string>
<string name="messages_create_public_private_chat_description">گروه خصوصی یا عمومی جدید</string>
<string name="messages_new_message">خصوصی</string> <string name="messages_new_message">خصوصی</string>
<string name="messages_new_message_to">به</string> <string name="messages_new_message_to">به</string>
<string name="messages_new_message_subject">موضوع</string> <string name="messages_new_message_subject">موضوع</string>
@@ -460,9 +567,13 @@
<string name="automatically_play_videos_description">پخش خودکار ویدئوها و جیف ها</string> <string name="automatically_play_videos_description">پخش خودکار ویدئوها و جیف ها</string>
<string name="automatically_show_url_preview_description">نشان دادن پیش نمایش URL</string> <string name="automatically_show_url_preview_description">نشان دادن پیش نمایش URL</string>
<string name="load_image_description">هنگام بارگیری تصاویر</string> <string name="load_image_description">هنگام بارگیری تصاویر</string>
<string name="copy_stack_to_clipboard">کپی استک</string>
<string name="copy_to_clipboard">کپی به کلیپ‌بورد</string> <string name="copy_to_clipboard">کپی به کلیپ‌بورد</string>
<string name="copy_npub_to_clipboard">کپی کلید عمومی در کلیپبورد</string>
<string name="share_or_save">اشتراک گذاری یا ذخیره‌سازی</string>
<string name="copy_url_to_clipboard">کپی URL به کلیپبورد</string> <string name="copy_url_to_clipboard">کپی URL به کلیپبورد</string>
<string name="copy_the_note_id_to_the_clipboard">کپی شناسه یادداشت به کلیپبورد</string> <string name="copy_the_note_id_to_the_clipboard">کپی شناسه یادداشت به کلیپبورد</string>
<string name="add_media_to_gallery">افزودن رسانه به گالری</string>
<string name="created_at">ایجاد شده در</string> <string name="created_at">ایجاد شده در</string>
<string name="rules">قوانین</string> <string name="rules">قوانین</string>
<string name="login_with_external_signer">ورود با Amber</string> <string name="login_with_external_signer">ورود با Amber</string>
@@ -473,6 +584,10 @@
<string name="error_dialog_talk_to_user">پیام به کاربر</string> <string name="error_dialog_talk_to_user">پیام به کاربر</string>
<string name="error_dialog_button_ok">قبول</string> <string name="error_dialog_button_ok">قبول</string>
<string name="relay_information_document_error_assemble_url">به %1$s نرسید: %2$s</string> <string name="relay_information_document_error_assemble_url">به %1$s نرسید: %2$s</string>
<string name="relay_information_document_error_failed_to_assemble_url">آدرس NIP-11 ساخته نشد به دلیل %1$s: %2$s</string>
<string name="relay_information_document_error_failed_to_reach_server">به %1$s نتوانست برسد: %2$s</string>
<string name="relay_information_document_error_failed_to_parse_response">پاسخ %1$s تفسیر نشد: %2$s</string>
<string name="relay_information_document_error_failed_with_http">رله درخواست %1$s را رد کرد: %2$s</string>
<string name="relay_information_document_error_reach_server">به %1$s نرسید: %2$s</string> <string name="relay_information_document_error_reach_server">به %1$s نرسید: %2$s</string>
<string name="relay_information_document_error_parse_result">نتایج %1$s تفسیر نشد: %2$s</string> <string name="relay_information_document_error_parse_result">نتایج %1$s تفسیر نشد: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s کد %2$s را انجام نداد</string> <string name="relay_information_document_error_http_status">%1$s کد %2$s را انجام نداد</string>
@@ -527,11 +642,16 @@
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">مشکل %1$s حل نشد. بررسی کنید که به اینترنت متصل باشید، سرور کار کند و آدرس لایتنینگ %2$s صحیح باشد. \n\nخطای %3$s</string> <string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">مشکل %1$s حل نشد. بررسی کنید که به اینترنت متصل باشید، سرور کار کند و آدرس لایتنینگ %2$s صحیح باشد. \n\nخطای %3$s</string>
<string name="could_not_fetch_invoice_from">صورتحساب از %1$s گرفته نشد</string> <string name="could_not_fetch_invoice_from">صورتحساب از %1$s گرفته نشد</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">خطا در تفسیر JSON از آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید.</string> <string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">خطا در تفسیر JSON از آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید.</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">خطا در تفسیر JSON از %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration">URL بازخوانی در پیکربندی سرور آدرس لایتنینگ یافت نشد</string> <string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration">URL بازخوانی در پیکربندی سرور آدرس لایتنینگ یافت نشد</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">فراخوانی URL از پاسخ %1$s یافت نشد</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">خطا در تفسیر JSON از فراخوان صورتحساب آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید.</string> <string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">خطا در تفسیر JSON از فراخوان صورتحساب آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید.</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">خطا در تفسیر JSON از دریافت صورتحساب %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید.</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">مبلغ نادرست صورتحساب (%1$s ساتوشی). می بایست %3$s ساتوشی باشد.</string> <string name="incorrect_invoice_amount_sats_from_it_should_have_been">مبلغ نادرست صورتحساب (%1$s ساتوشی). می بایست %3$s ساتوشی باشد.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. کیف پول لایتنینگی گیرنده خطای روبرو را داد: %1$s</string> <string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. کیف پول لایتنینگی گیرنده خطای روبرو را داد: %1$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. المنت pr در JSON بدست آمده یافت نشد.</string> <string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. المنت pr در JSON بدست آمده یافت نشد.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">نمی توان صورتحساب لایتنینگ از %1$s ساخت. المنت pr در JSON بدست آمده یافت نشد.</string>
<string name="read_only_user">کاربر با امکان خواندن فقط</string> <string name="read_only_user">کاربر با امکان خواندن فقط</string>
<string name="no_reactions_setup">هیچ واکنشی تنظیم نشده است</string> <string name="no_reactions_setup">هیچ واکنشی تنظیم نشده است</string>
<string name="select_push_server">یک اپلیکیشن UnifiedPush انتخاب کنید</string> <string name="select_push_server">یک اپلیکیشن UnifiedPush انتخاب کنید</string>
@@ -555,6 +675,7 @@
<string name="classifieds_condition">شرایط</string> <string name="classifieds_condition">شرایط</string>
<string name="classifieds_category">دسته بندی</string> <string name="classifieds_category">دسته بندی</string>
<string name="classifieds_price">قیمت (به ساتوشی)</string> <string name="classifieds_price">قیمت (به ساتوشی)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">موقعیت مکانی</string> <string name="classifieds_location">موقعیت مکانی</string>
<string name="classifieds_location_placeholder">شهر، استان، کشور</string> <string name="classifieds_location_placeholder">شهر، استان، کشور</string>
<string name="classifieds_condition_new">جدید</string> <string name="classifieds_condition_new">جدید</string>
@@ -588,4 +709,79 @@
<string name="server_did_not_provide_a_url_after_uploading">سرور پس از بارگذاری URL نداد</string> <string name="server_did_not_provide_a_url_after_uploading">سرور پس از بارگذاری URL نداد</string>
<string name="could_not_download_from_the_server">فایل بارگذاری شده از سرور بارگیری نشد</string> <string name="could_not_download_from_the_server">فایل بارگذاری شده از سرور بارگیری نشد</string>
<string name="could_not_prepare_local_file_to_upload">فایل محلی برای بارگذاری آماده نشد: %1$s</string> <string name="could_not_prepare_local_file_to_upload">فایل محلی برای بارگذاری آماده نشد: %1$s</string>
<string name="failed_to_upload_with_message">بارگذاری نشد: %1$s</string>
<string name="failed_to_delete_with_message">حذف نشد: %1$s</string>
<string name="media_too_big_for_nip95">رسانه برای NIP-95 بسیار بزرگ است</string>
<string name="unable_to_load_thumbnail">ریزتصویر بارگیری نشد</string>
<string name="could_not_prepare_header">اطلاعات سربرگ آماده نشد: %1$s</string>
<string name="compression_cancelled">فشرده سازی لغو شد</string>
<string name="compression_returned_null">فشرده سازی فایل خروجی نساخت</string>
<string name="media_compression_quality_label">کیفیت رسانه</string>
<string name="media_compression_quality_explainer">کیفیت کم را انتخاب کنید تا رسانه را به شکل فایل کوچکتری ذخیره کند، کیفیت بالا برای ذخیره به شکل فایل بزرگتر و نافشرده برای بارگذاری رسانه بدون فشرده سازی است.</string>
<string name="media_compression_quality_low">پایین</string>
<string name="media_compression_quality_medium">متوسط</string>
<string name="media_compression_quality_high">بالا</string>
<string name="media_compression_quality_uncompressed">نافشرده</string>
<string name="edit_draft">ویرایش پیش‌نویس</string>
<string name="login_with_qr_code">ورود با کد QR</string>
<string name="route">مسیر</string>
<string name="route_home">خانه</string>
<string name="route_search">جستجو</string>
<string name="route_discover">کاوش</string>
<string name="route_messages">پیغام‌ها</string>
<string name="route_notifications">اعلان‌ها</string>
<string name="route_global">سراسری</string>
<string name="route_video">کوتاه</string>
<string name="route_security_filters">فیلترهای ایمنی</string>
<string name="new_post">نوشته جدید</string>
<string name="new_short">رسانه های کوتاه جدید: تصاویر یا ویدئوها</string>
<string name="new_community_note">یادداشت جدید در انجمن</string>
<string name="open_all_reactions_to_this_post">باز کردن تمام واکنش ها به این پست</string>
<string name="close_all_reactions_to_this_post">بستن تمام واکنش ها به این پست</string>
<string name="reply_description">پاسخ</string>
<string name="boost_or_quote_description">بازنشر یا نقل قول</string>
<string name="like_description">پسند</string>
<string name="zap_description">زَپ</string>
<string name="change_reaction">تغییر واکنش های سریع</string>
<string name="profile_image_of_user">تصویر نمایه %1$s</string>
<string name="relay_info">رله %1$s</string>
<string name="expand_relay_list">گسترش لیست رله</string>
<string name="note_options">گزینه های یادداشت</string>
<string name="relay_list_selector">انتخابگر لیست رله</string>
<string name="poll">نظرسنجی</string>
<string name="disable_poll">غیرفعال کردن نظرسنجی</string>
<string name="add_bitcoin_invoice">صورت حساب بیتکوین</string>
<string name="cancel_bitcoin_invoice">لغو صورت حساب بیتکوین</string>
<string name="cancel_classifieds">لغو فروش</string>
<string name="add_zapraiser">زپ ریزون</string>
<string name="cancel_zapraiser">لغو زپ ریزون</string>
<string name="add_location">موقعیت مکانی</string>
<string name="remove_location">حذف موقعیت مکانی</string>
<string name="add_zap_split">تقسیم زپ</string>
<string name="cancel_zap_split">لغو تقسیم زپ</string>
<string name="add_content_warning">افزودن هشدار محتوا</string>
<string name="remove_content_warning">حذف هشدار محتوا</string>
<string name="show_npub_as_a_qr_code">کلید عمومی را به شکل کد QR نشان بده</string>
<string name="invalid_nip19_uri">آدرس نامعتبر</string>
<string name="invalid_nip19_uri_description">امتیست آدرسی برای باز کردن دریافت کرد ولی آدرس نامعتبر بود: %1$s</string>
<string name="dm_relays_title">رله های صندوق پیام خصوصی</string>
<string name="dm_relays_through">رله ها: %1$s</string>
<string name="dm_relays_regular">استفاده از رله های معمولی</string>
<string name="dm_relays_not_found">تنظیم رله های صندوق ورودی خصوصی</string>
<string name="dm_relays_not_found_description">با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید.</string>
<string name="dm_relays_not_found_examples">گزینه های خوب:
- inbox.nostr.wine (پولی)
- auth.nostr1.com (رایگان)
- you.nostr1.com (رله شخصی - پولی)</string>
<string name="dm_relays_not_found_examples2">گزینه های خوب:
- inbox.nostr.wine (پولی)
- auth.nostr1.com (رایگان)
- you.nostr1.com (رله شخصی - پولی)</string>
<string name="dm_relays_not_found_create_now">هم اکنون تنظیم شود</string>
<string name="search_relays_title">رله های جستجو</string>
<string name="search_relays_not_found">تنظیم رله های جستجو</string>
<string name="zap_the_devs_title">به توسعه دهندگان رپ دهید!</string>
<string name="accessibility_lyrics_off">متن خاموش</string>
<string name="accessibility_send">ارسال</string>
<string name="draft_note">پیش نویس یادداشت</string>
</resources> </resources>
@@ -540,6 +540,7 @@
<string name="nip05_checking">Vérification de l\'adresse Nostr</string> <string name="nip05_checking">Vérification de l\'adresse Nostr</string>
<string name="select_deselect_all">Tout sélectionner/désélectionner</string> <string name="select_deselect_all">Tout sélectionner/désélectionner</string>
<string name="default_relays">Défaut</string> <string name="default_relays">Défaut</string>
<string name="default_relays_longer">Rétablir les valeurs par défaut</string>
<string name="select_a_relay_to_continue">Sélectionner un relay pour continuer</string> <string name="select_a_relay_to_continue">Sélectionner un relay pour continuer</string>
<string name="zap_forward_title">Transférer les Zaps à:</string> <string name="zap_forward_title">Transférer les Zaps à:</string>
<string name="zap_forward_explainer">Les clients compatibles transmettront des zaps sur l\'adresse LN ou le profil Utilisateur ci-dessous au lieu du vôtre.</string> <string name="zap_forward_explainer">Les clients compatibles transmettront des zaps sur l\'adresse LN ou le profil Utilisateur ci-dessous au lieu du vôtre.</string>
@@ -769,6 +770,7 @@
<string name="dm_relays_not_found">Configurer vos relais de messagerie privée</string> <string name="dm_relays_not_found">Configurer vos relais de messagerie privée</string>
<string name="dm_relays_not_found_description">Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages.</string> <string name="dm_relays_not_found_description">Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages.</string>
<string name="dm_relays_not_found_examples">Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant)</string> <string name="dm_relays_not_found_examples">Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="dm_relays_not_found_examples2">De bonnes options sont:\n - auth.nostr1.com (gratuit)\n - inbox.nostr.wine (payant)\n - relay.0xchat.com (gratuit)</string>
<string name="dm_relays_not_found_editing">Insérez entre 1-3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger.</string> <string name="dm_relays_not_found_editing">Insérez entre 1-3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger.</string>
<string name="dm_relays_not_found_create_now">Configurer maintenant</string> <string name="dm_relays_not_found_create_now">Configurer maintenant</string>
<string name="search_relays_title">Relais de recherche</string> <string name="search_relays_title">Relais de recherche</string>
@@ -539,6 +539,7 @@
<string name="nip05_checking">A Nostr cím ellenőrzése</string> <string name="nip05_checking">A Nostr cím ellenőrzése</string>
<string name="select_deselect_all">Mind kijelölése/kijelölés visszavonása</string> <string name="select_deselect_all">Mind kijelölése/kijelölés visszavonása</string>
<string name="default_relays">Alapértelmezett</string> <string name="default_relays">Alapértelmezett</string>
<string name="default_relays_longer">Visszaállítás alaphelyzetbe</string>
<string name="select_a_relay_to_continue">A folytatáshoz válassz egy csomópontot</string> <string name="select_a_relay_to_continue">A folytatáshoz válassz egy csomópontot</string>
<string name="zap_forward_title">Zap-ek továbbítása:</string> <string name="zap_forward_title">Zap-ek továbbítása:</string>
<string name="zap_forward_explainer">A funkciót támogató kliensek a Zap-eket az Ön tárcája helyett, az alábbi LN-címre vagy felhasználói profilra továbbítják</string> <string name="zap_forward_explainer">A funkciót támogató kliensek a Zap-eket az Ön tárcája helyett, az alábbi LN-címre vagy felhasználói profilra továbbítják</string>
@@ -768,6 +769,7 @@
<string name="dm_relays_not_found">Állítsd be a Privát postafiókod közvetítőit</string> <string name="dm_relays_not_found">Állítsd be a Privát postafiókod közvetítőit</string>
<string name="dm_relays_not_found_description">Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz.</string> <string name="dm_relays_not_found_description">Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz.</string>
<string name="dm_relays_not_found_examples">Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string> <string name="dm_relays_not_found_examples">Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string>
<string name="dm_relays_not_found_examples2">Jó lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes)</string>
<string name="dm_relays_not_found_editing">Adj hozzá 13 csomópontot, hogy privát postafiókodként szolgáljon. A PÜ Bejővő csomópontoknak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Te általad teszik lehetővé.</string> <string name="dm_relays_not_found_editing">Adj hozzá 13 csomópontot, hogy privát postafiókodként szolgáljon. A PÜ Bejővő csomópontoknak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Te általad teszik lehetővé.</string>
<string name="dm_relays_not_found_create_now">Állítsd be most</string> <string name="dm_relays_not_found_create_now">Állítsd be most</string>
<string name="search_relays_title">Kereső Csomópontok</string> <string name="search_relays_title">Kereső Csomópontok</string>
@@ -27,10 +27,16 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string>
<string name="cashu_failed_redemption">Cashu-ja ni bilo mogoče unovčiti</string> <string name="cashu_failed_redemption">Cashu-ja ni bilo mogoče unovčiti</string>
<string name="cashu_no_wallet_found">Na sistemu ni najdene združljive Cashu denarnice</string> <string name="cashu_no_wallet_found">Na sistemu ni najdene združljive Cashu denarnice</string>
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">Prejemnikova \"lightning\" storitev na %1$s ni na voljo. Izračunana je bila iz \"lightning\" naslova \'%2$s\'. Napaka: %3$s. Preverite, ali strežnik deluje in ali je \"lightning\" naslov pravilen</string> <string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">Prejemnikova \"lightning\" storitev na %1$s ni na voljo. Izračunana je bila iz \"lightning\" naslova \'%2$s\'. Napaka: %3$s. Preverite, ali strežnik deluje in ali je \"lightning\" naslov pravilen</string>
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">Ni bilo mogoče razrešiti %1$s. Preverite, ali ste povezani, ali je strežnik dosegljiv in ali je lightning naslov %2$s pravilen.\n\nIzjema je bila: %3$s</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\"</string> <string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\"</string>
<string name="select_push_server">Izberi \"UnifiedPush\" aplikacijo</string> <string name="select_push_server">Izberi \"UnifiedPush\" aplikacijo</string>
<string name="push_server_install_app_description"> Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). <string name="push_server_install_app_description"> Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/).
Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah.
</string> </string>
<string name="classifieds_condition_fair_explainer">V zadovoljivem stanju</string>
<string name="classifieds_category_clothing">Oblačila</string>
<string name="classifieds_category_crafts">Rokodelstvo</string>
<string name="classifieds_category_other">Drugo</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">Osnutkom zapisa ni mogoče pošiljati mikro plačil (zap)</string>
<string name="http_status_416">Razpon ni zadovoljiv Strežnik ne more izpolniti vrednosti, navedene v \"requests Range header\" polju. </string> <string name="http_status_416">Razpon ni zadovoljiv Strežnik ne more izpolniti vrednosti, navedene v \"requests Range header\" polju. </string>
</resources> </resources>
+3
View File
@@ -416,6 +416,7 @@
<string name="follow_list_selection">Follow List</string> <string name="follow_list_selection">Follow List</string>
<string name="follow_list_kind3follows">All Follows</string> <string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Mute List</string> <string name="follow_list_mute_list">Mute List</string>
@@ -635,6 +636,7 @@
<string name="nip05_checking">Checking Nostr address</string> <string name="nip05_checking">Checking Nostr address</string>
<string name="select_deselect_all">Select/Deselect all</string> <string name="select_deselect_all">Select/Deselect all</string>
<string name="default_relays">Default</string> <string name="default_relays">Default</string>
<string name="default_relays_longer">Reset to Defaults</string>
<string name="select_a_relay_to_continue">Select a relay to continue</string> <string name="select_a_relay_to_continue">Select a relay to continue</string>
<string name="zap_forward_title">Forward Zaps to:</string> <string name="zap_forward_title">Forward Zaps to:</string>
@@ -929,6 +931,7 @@
<string name="dm_relays_not_found">Set up your Private Inbox relays</string> <string name="dm_relays_not_found">Set up your Private Inbox relays</string>
<string name="dm_relays_not_found_description">This setting lets everybody know which relays to use when sending messages to you. Without them you might miss some messages.</string> <string name="dm_relays_not_found_description">This setting lets everybody know which relays to use when sending messages to you. Without them you might miss some messages.</string>
<string name="dm_relays_not_found_examples">Good options are:\n - inbox.nostr.wine (paid)\n - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid)</string> <string name="dm_relays_not_found_examples">Good options are:\n - inbox.nostr.wine (paid)\n - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid)</string>
<string name="dm_relays_not_found_examples2">Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free)</string>
<string name="dm_relays_not_found_editing">Insert between 13 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string> <string name="dm_relays_not_found_editing">Insert between 13 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string>
<string name="dm_relays_not_found_create_now">Set up now</string> <string name="dm_relays_not_found_create_now">Set up now</string>
+7 -7
View File
@@ -14,23 +14,23 @@ benchmarkJunit4 = "1.3.3"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
blurhash = "1.0.0" blurhash = "1.0.0"
coil = "2.7.0" coil = "2.7.0"
composeBom = "2024.10.00" composeBom = "2024.10.01"
coreKtx = "1.13.1" coreKtx = "1.15.0"
espressoCore = "3.6.1" espressoCore = "3.6.1"
firebaseBom = "33.4.0" firebaseBom = "33.5.1"
fragmentKtx = "1.8.4" fragmentKtx = "1.8.5"
gms = "4.4.2" gms = "4.4.2"
jacksonModuleKotlin = "2.17.2" jacksonModuleKotlin = "2.17.2"
jna = "5.14.0" jna = "5.14.0"
jtorctl = "0.4.5.7" jtorctl = "0.4.5.7"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.0.0" kotlin = "2.0.20"
kotlinxCollectionsImmutable = "0.3.7" kotlinxCollectionsImmutable = "0.3.7"
kotlinxSerialization = "1.7.2" kotlinxSerialization = "1.7.2"
kotlinxSerializationPlugin = "2.0.0" kotlinxSerializationPlugin = "2.0.0"
languageId = "17.0.6" languageId = "17.0.6"
lazysodiumAndroid = "5.1.0" lazysodiumAndroid = "5.1.0"
lifecycleRuntimeKtx = "2.8.6" lifecycleRuntimeKtx = "2.8.7"
lightcompressor = "1.3.2" lightcompressor = "1.3.2"
markdown = "077a2cde64" markdown = "077a2cde64"
media3 = "1.4.1" media3 = "1.4.1"
@@ -53,7 +53,7 @@ zoomable = "1.6.2"
zxing = "3.5.3" zxing = "3.5.3"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.3.0" windowCoreAndroid = "1.3.0"
androidxCamera = "1.3.4" androidxCamera = "1.4.0"
[libraries] [libraries]
abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+3
View File
@@ -61,6 +61,9 @@ dependencies {
implementation "com.goterl:lazysodium-android:5.1.0@aar" implementation "com.goterl:lazysodium-android:5.1.0@aar"
implementation 'net.java.dev.jna:jna:5.14.0@aar' implementation 'net.java.dev.jna:jna:5.14.0@aar'
//implementation (libs.lazysodium.android) { artifact { type = "aar" } }
//implementation (libs.jna) { artifact { type = "aar" } }
// Performant Parser of JSONs into Events // Performant Parser of JSONs into Events
api libs.jackson.module.kotlin api libs.jackson.module.kotlin
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.crypto.nip44
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.crypto.nip01.Nip01 import com.vitorpamplona.quartz.crypto.nip01.Nip01
@@ -191,74 +190,3 @@ class NIP44v2Test {
return MessageDigest.getInstance("SHA-256").digest(data).toHexKey() return MessageDigest.getInstance("SHA-256").digest(data).toHexKey()
} }
} }
data class VectorFile(
val v2: V2? = V2(),
)
data class V2(
val valid: Valid? = Valid(),
val invalid: Invalid? = Invalid(),
)
data class Valid(
@JsonProperty("get_conversation_key")
val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(),
@JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList<ArrayList<Int>> = arrayListOf(),
@JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList<EncryptDecrypt> = arrayListOf(),
@JsonProperty("encrypt_decrypt_long_msg")
val encryptDecryptLongMsg: ArrayList<EncryptDecryptLongMsg> = arrayListOf(),
)
data class Invalid(
@JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList<Int> = arrayListOf(),
@JsonProperty("get_conversation_key")
val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@JsonProperty("decrypt") val decrypt: ArrayList<Decrypt> = arrayListOf(),
)
data class GetConversationKey(
val sec1: String? = null,
val pub2: String? = null,
val note: String? = null,
@JsonProperty("conversation_key") val conversationKey: String? = null,
)
data class GetMessageKeys(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val keys: ArrayList<Keys> = arrayListOf(),
)
data class Keys(
@JsonProperty("nonce") val nonce: String? = null,
@JsonProperty("chacha_key") val chachaKey: String? = null,
@JsonProperty("chacha_nonce") val chachaNonce: String? = null,
@JsonProperty("hmac_key") val hmacKey: String? = null,
)
data class EncryptDecrypt(
val sec1: String? = null,
val sec2: String? = null,
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
)
data class EncryptDecryptLongMsg(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val pattern: String? = null,
val repeat: Int? = null,
@JsonProperty("plaintext_sha256") val plaintextSha256: String? = null,
@JsonProperty("payload_sha256") val payloadSha256: String? = null,
)
data class Decrypt(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
val note: String? = null,
)
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.crypto.nip44
import com.fasterxml.jackson.annotation.JsonProperty
data class VectorFile(
val v2: V2? = V2(),
)
data class V2(
val valid: Valid? = Valid(),
val invalid: Invalid? = Invalid(),
)
data class Valid(
@JsonProperty("get_conversation_key")
val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(),
@JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList<ArrayList<Int>> = arrayListOf(),
@JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList<EncryptDecrypt> = arrayListOf(),
@JsonProperty("encrypt_decrypt_long_msg")
val encryptDecryptLongMsg: ArrayList<EncryptDecryptLongMsg> = arrayListOf(),
)
data class Invalid(
@JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList<Int> = arrayListOf(),
@JsonProperty("get_conversation_key")
val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@JsonProperty("decrypt") val decrypt: ArrayList<Decrypt> = arrayListOf(),
)
data class GetConversationKey(
val sec1: String? = null,
val pub2: String? = null,
val note: String? = null,
@JsonProperty("conversation_key") val conversationKey: String? = null,
)
data class GetMessageKeys(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val keys: ArrayList<Keys> = arrayListOf(),
)
data class Keys(
@JsonProperty("nonce") val nonce: String? = null,
@JsonProperty("chacha_key") val chachaKey: String? = null,
@JsonProperty("chacha_nonce") val chachaNonce: String? = null,
@JsonProperty("hmac_key") val hmacKey: String? = null,
)
data class EncryptDecrypt(
val sec1: String? = null,
val sec2: String? = null,
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
)
data class EncryptDecryptLongMsg(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val pattern: String? = null,
val repeat: Int? = null,
@JsonProperty("plaintext_sha256") val plaintextSha256: String? = null,
@JsonProperty("payload_sha256") val payloadSha256: String? = null,
)
data class Decrypt(
@JsonProperty("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
val note: String? = null,
)
@@ -119,6 +119,8 @@ class ContactListEvent(
const val KIND = 3 const val KIND = 3
const val ALT = "Follow List" const val ALT = "Follow List"
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
fun createFromScratch( fun createFromScratch(
followUsers: List<Contact> = emptyList(), followUsers: List<Contact> = emptyList(),
followTags: List<String> = emptyList(), followTags: List<String> = emptyList(),
@@ -107,6 +107,19 @@ open class Event(
} }
} }
override fun anyHashTag(onEach: (str: String) -> Boolean) = anyTagged("t", onEach)
private fun anyTagged(
tagName: String,
onEach: (str: String) -> Boolean,
) = tags.any {
if (it.size > 1 && it[0] == tagName) {
onEach(it[1])
} else {
false
}
}
override fun <R> mapTaggedEvent(map: (eventId: HexKey) -> R) = mapTagged("e", map) override fun <R> mapTaggedEvent(map: (eventId: HexKey) -> R) = mapTagged("e", map)
override fun <R> mapTaggedAddress(map: (address: String) -> R) = mapTagged("a", map) override fun <R> mapTaggedAddress(map: (address: String) -> R) = mapTagged("a", map)
@@ -121,6 +121,8 @@ interface EventInterface {
fun forEachHashTag(onEach: (eventId: HexKey) -> Unit) fun forEachHashTag(onEach: (eventId: HexKey) -> Unit)
fun anyHashTag(onEach: (str: String) -> Boolean): Boolean
fun <R> mapTaggedEvent(map: (eventId: HexKey) -> R): List<R> fun <R> mapTaggedEvent(map: (eventId: HexKey) -> R): List<R>
fun <R> mapTaggedAddress(map: (address: String) -> R): List<R> fun <R> mapTaggedAddress(map: (address: String) -> R): List<R>
@@ -87,6 +87,11 @@ class TextNoteEvent(
tags.add(arrayOf("q", it)) tags.add(arrayOf("q", it))
} }
} }
addresses?.forEach {
if (it.toTag() in directMentions) {
tags.add(arrayOf("q", it.toTag()))
}
}
addresses addresses
?.map { it.toTag() } ?.map { it.toTag() }
?.let { ?.let {