diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4840fc747..eebabdfba 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,4 +30,6 @@ If applicable, add a video and/or screenshots to help explain your problem. - Amber Version (if using it to sign): **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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a6a8431b3..1e2172fd3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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** A clear and concise description of what you want to happen. **Bounty (in Bitcoin sats) offered for the implementation** -Incentivize developers to work on your feature. Describe clear milestones to claim payment. - -**Additional context** -Add any other context, video, or screenshots about the feature request here. +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. +We prioritize feature development by its bounty size compared to how much work is required to get it done. \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 6d0ee1c2a..d4b7accba 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index fe2d755b6..41702f74c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -32,6 +32,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import coil.ImageLoader import coil.disk.DiskCache import coil.memory.MemoryCache +import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.playback.VideoCache import com.vitorpamplona.ammolite.service.HttpClientManager import kotlinx.coroutines.CoroutineScope @@ -50,6 +51,7 @@ class Amethyst : Application() { // Service Manager is only active when the activity is active. val serviceManager = ServiceManager(applicationIOScope) + val locationManager = LocationState(this, applicationIOScope) override fun onTerminate() { super.onTerminate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 8eb3a9460..8ec6b4900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import android.location.Location import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable @@ -28,6 +29,8 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.switchMap 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.service.FileHeader import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource @@ -120,10 +123,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update @@ -160,17 +161,19 @@ class Account( val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) @Immutable - class LiveFollowLists( - val users: Set = emptySet(), - val usersPlusMe: Set, + class LiveFollowList( + val authors: Set = emptySet(), + val authorsPlusMe: Set, val hashtags: Set = emptySet(), val geotags: Set = emptySet(), - val communities: Set = emptySet(), + val addresses: Set = emptySet(), ) - class ListNameNotePair( + class FeedsBaseFlows( val listName: String, - val event: GeneralListEvent?, + val peopleList: StateFlow = MutableStateFlow(NoteState(Note(" "))), + val kind3: StateFlow = MutableStateFlow(null), + val location: StateFlow = MutableStateFlow(null), ) val connectToRelaysFlow = @@ -218,7 +221,7 @@ class Account( localRelayList: Set, ): List { 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 = nip65RelayEvent?.relays()?.map { AdvertisedRelayListEvent.AdvertisedRelayInfo( @@ -465,23 +468,26 @@ class Account( }.toTypedArray(), ) - fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists { + fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList { // makes sure the output include only valid p tags val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() - return LiveFollowLists( - verifiedFollowingUsers, - verifiedFollowingUsers + signer.pubKey, - latestContactList - ?.unverifiedFollowTagSet() - ?.map { it.lowercase() } - ?.toSet() ?: emptySet(), - latestContactList - ?.unverifiedFollowGeohashSet() - ?.toSet() ?: emptySet(), - latestContactList - ?.verifiedFollowAddressSet() - ?.toSet() ?: emptySet(), + return LiveFollowList( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + hashtags = + latestContactList + ?.unverifiedFollowTagSet() + ?.map { it.lowercase() } + ?.toSet() ?: emptySet(), + geotags = + latestContactList + ?.unverifiedFollowGeohashSet() + ?.toSet() ?: emptySet(), + addresses = + latestContactList + ?.verifiedFollowAddressSet() + ?.toSet() ?: emptySet(), ) } @@ -516,7 +522,7 @@ class Account( ) @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow = + val liveKind3FollowsFlow: Flow = userProfile().flow().follows.stateFlow.transformLatest { checkNotInMainThread() emit(buildFollowLists(it.user.latestContactList)) @@ -531,80 +537,102 @@ class Account( buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), ) - @OptIn(ExperimentalCoroutinesApi::class) - private val liveHomeList: Flow = - settings.defaultHomeFollowList.flatMapLatest { listName -> - loadPeopleListFlowFromListName(listName) - } - - fun peopleListFromListNameStarter(listName: String): ListNameNotePair = - if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - val noteEvent = note?.event as? GeneralListEvent - ListNameNotePair(listName, noteEvent) - } else { - ListNameNotePair(listName, null) - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun loadPeopleListFlowFromListName(listName: String): Flow = - if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - note?.flow()?.metadata?.stateFlow?.mapLatest { - val noteEvent = it.note.event as? GeneralListEvent - 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 loadFlowsFor(listName: String): FeedsBaseFlows = + when (listName) { + GLOBAL_FOLLOWS -> FeedsBaseFlows(listName) + KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows) + AROUND_ME -> + FeedsBaseFlows( + listName, + location = Amethyst.instance.locationManager.locationStateFlow, + ) + else -> { + val note = LocalCache.checkGetOrCreateAddressableNote(listName) + if (note != null) { + FeedsBaseFlows( + listName, + peopleList = + note + .flow() + .metadata.stateFlow, + ) + } else { + FeedsBaseFlows(listName) + } } } - fun combinePeopleListFlows( - kind3FollowsSource: Flow, - peopleListFollowsSource: Flow, - ): Flow = - combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows -> - checkNotInMainThread() - emit(combinePeopleList(kind3Follows, peopleListFollows)) + suspend fun mapIntoFollowLists( + listName: String, + kind3: LiveFollowList?, + noteState: NoteState, + location: Location?, + ): LiveFollowList? = + 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 by lazy { - combinePeopleListFlows(liveKind3Follows, liveHomeList) - } + @OptIn(ExperimentalCoroutinesApi::class) + fun combinePeopleListFlows(peopleListFollowsSource: Flow): Flow = + 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 by lazy { - liveHomeFollowListFlow + val liveHomeFollowLists: StateFlow by lazy { + combinePeopleListFlows(settings.defaultHomeFollowList) .flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, runBlocking { - combinePeopleList( - liveKind3Follows.value, - peopleListFromListNameStarter(settings.defaultHomeFollowList.value), - ) + loadAndCombineFlows(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 * for each user pubkey, a list of valid relays. @@ -701,7 +729,7 @@ class Account( liveHomeFollowLists .transformLatest { followList -> if (followList != null) { - emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) + emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) } else { emit(null) } @@ -728,53 +756,33 @@ class Account( scope, SharingStarted.Eagerly, 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 }, settings.torSettings.torType.value, ).ifEmpty { null }, ) } - @OptIn(ExperimentalCoroutinesApi::class) - private val liveNotificationList: Flow by lazy { - settings.defaultNotificationFollowList.flatMapLatest { listName -> - loadPeopleListFlowFromListName(listName) - } - } - - val liveNotificationFollowLists: StateFlow by lazy { - combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList) + val liveNotificationFollowLists: StateFlow by lazy { + combinePeopleListFlows(settings.defaultNotificationFollowList) .flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, runBlocking { - combinePeopleList( - liveKind3Follows.value, - peopleListFromListNameStarter(settings.defaultNotificationFollowList.value), - ) + loadAndCombineFlows(settings.defaultNotificationFollowList.value) }, ) } - @OptIn(ExperimentalCoroutinesApi::class) - private val liveStoriesList: Flow by lazy { - settings.defaultStoriesFollowList.flatMapLatest { listName -> - loadPeopleListFlowFromListName(listName) - } - } - - val liveStoriesFollowLists: StateFlow by lazy { - combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList) + val liveStoriesFollowLists: StateFlow by lazy { + combinePeopleListFlows(settings.defaultStoriesFollowList) .flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, runBlocking { - combinePeopleList( - liveKind3Follows.value, - peopleListFromListNameStarter(settings.defaultStoriesFollowList.value), - ) + loadAndCombineFlows(settings.defaultStoriesFollowList.value) }, ) } @@ -784,7 +792,7 @@ class Account( liveStoriesFollowLists .transformLatest { followList -> if (followList != null) { - emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) + emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) } else { emit(null) } @@ -811,31 +819,21 @@ class Account( scope, SharingStarted.Eagerly, 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 }, settings.torSettings.torType.value, ).ifEmpty { null }, ) } - @OptIn(ExperimentalCoroutinesApi::class) - private val liveDiscoveryList: Flow by lazy { - settings.defaultDiscoveryFollowList.flatMapLatest { listName -> - loadPeopleListFlowFromListName(listName) - } - } - - val liveDiscoveryFollowLists: StateFlow by lazy { - combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList) + val liveDiscoveryFollowLists: StateFlow by lazy { + combinePeopleListFlows(settings.defaultDiscoveryFollowList) .flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, runBlocking { - combinePeopleList( - liveKind3Follows.value, - peopleListFromListNameStarter(settings.defaultDiscoveryFollowList.value), - ) + loadAndCombineFlows(settings.defaultDiscoveryFollowList.value) }, ) } @@ -845,7 +843,7 @@ class Account( liveDiscoveryFollowLists .transformLatest { followList -> if (followList != null) { - emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) + emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) } else { emit(null) } @@ -872,7 +870,7 @@ class Account( scope, SharingStarted.Eagerly, authorsPerRelay( - liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), + liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), connectToRelays.value.filter { it.read }.map { it.url }, settings.torSettings.torType.value, ).ifEmpty { null }, @@ -881,19 +879,17 @@ class Account( private fun decryptLiveFollows( listEvent: GeneralListEvent, - onReady: (LiveFollowLists) -> Unit, + onReady: (LiveFollowList) -> Unit, ) { listEvent.privateTags(signer) { privateTagList -> val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet() onReady( - LiveFollowLists( - users = users, - usersPlusMe = users + userProfile().pubkeyHex, - hashtags = - (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), - geotags = - (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), - communities = + LiveFollowList( + authors = users, + authorsPlusMe = users + userProfile().pubkeyHex, + hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), + geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), + addresses = (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) .map { it.toTag() } .toSet(), @@ -902,7 +898,7 @@ class Account( } } - suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowLists? = + suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? = withTimeoutOrNull(1000) { suspendCancellableCoroutine { continuation -> decryptLiveFollows(peopleListFollows) { @@ -3210,7 +3206,7 @@ class Account( flowHiddenUsers.value.hiddenUsers.contains(userHex) || flowHiddenUsers.value.spammers.contains(userHex) - fun followingKeySet(): Set = liveKind3Follows.value.users + fun followingKeySet(): Set = liveKind3Follows.value.authors fun isAcceptable(user: User): Boolean { if (userProfile().pubkeyHex == user.pubkeyHex) { @@ -3268,8 +3264,8 @@ class Account( } return ( - note.reportsBy(liveKind3Follows.value.usersPlusMe) + - (note.author?.reportsBy(liveKind3Follows.value.usersPlusMe) ?: emptyList()) + + note.reportsBy(liveKind3Follows.value.authorsPlusMe) + + (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) + innerReports ).toSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 81d6f2731..b767838f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -67,9 +67,9 @@ val DefaultNIP65List = val DefaultDMRelayList = listOf( - RelayUrlFormatter.normalize("wss://auth.nostr1.com/"), - RelayUrlFormatter.normalize("wss://nostr.mom/"), - RelayUrlFormatter.normalize("wss://nos.lol/"), + RelayUrlFormatter.normalize("wss://auth.nostr1.com"), + RelayUrlFormatter.normalize("wss://relay.0xchat.com"), + RelayUrlFormatter.normalize("wss://nos.lol"), ) val DefaultSearchRelayList = @@ -77,6 +77,7 @@ val DefaultSearchRelayList = RelayUrlFormatter.normalize("wss://relay.nostr.band"), RelayUrlFormatter.normalize("wss://nostr.wine"), 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. @@ -85,6 +86,9 @@ val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. 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 class AccountSettings( val keyPair: KeyPair, @@ -357,7 +361,6 @@ class AccountSettings( // Events might be different objects, we have to compare their ids. if (backupAppSpecificData?.id != appSettings.id) { - println("AABBCC Update App Specific Data") backupAppSpecificData = appSettings syncedSettings.updateFrom(newSyncedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 4bc0d4a82..0f94e7ffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2043,7 +2043,10 @@ object LocalCache { } } - fun findUsersStartingWith(username: String): List { + fun findUsersStartingWith( + username: String, + forAccount: Account?, + ): List { checkNotInMainThread() val key = decodePublicKeyAsHexOrNull(username) @@ -2056,26 +2059,19 @@ object LocalCache { } return users.filter { _, user: User -> - (user.anyNameStartsWith(username)) || - user.pubkeyHex.startsWith(username, true) || - user.pubkeyNpub().startsWith(username, true) + ( + (user.anyNameStartsWith(username)) || + 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 { - checkNotInMainThread() - - return addressables - .filter { _, note -> - val listEvent = note.event - ( - listEvent is PeopleListEvent && - user.pubkeyHex == listEvent.pubKey - ) - } - } - - fun findNotesStartingWith(text: String): List { + fun findNotesStartingWith( + text: String, + forAccount: Account, + ): List { checkNotInMainThread() val key = decodeEventIdAsHexOrNull(text) @@ -2102,11 +2098,19 @@ object LocalCache { note.idHex.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) { - 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 @@ -2125,11 +2129,19 @@ object LocalCache { if (addressable.event?.matchTag1With(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) { - 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 6c661d56c..bb88bf0b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -771,29 +771,11 @@ open class Note( return true } - if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) { + if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) { return true } - if (author?.profilePicture()?.containsAny(accountChoices.hiddenWordsCase) == 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 - } + if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true } return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 8f8ea2534..344dfd5c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.UserMetadata 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.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -371,6 +373,36 @@ class User( (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } } != null + fun containsAny(hiddenWordsCase: List): 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 var liveSet: UserLiveSet? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt index 79ffb292c..aa367873a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt @@ -26,75 +26,93 @@ import android.location.Geocoder import android.location.Location import android.location.LocationListener import android.location.LocationManager -import android.os.HandlerThread +import android.os.Looper +import android.util.Log import android.util.LruCache -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import com.fonfon.kgeohash.toGeoHash +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.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( - context: Context, +class LocationFlow( + 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(LocationManager.NETWORK_PROVIDER)) - val providerState = mutableStateOf(false) - val isStart: MutableState = mutableStateOf(false) - - private val locHandlerThread = HandlerThread("LocationUtil Thread") - - init { - locHandlerThread.start() - } - @SuppressLint("MissingPermission") - fun start( + fun get( minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE, - ) { - locationListener().let { - locationListener = it + ): Flow = + callbackFlow { + 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.NETWORK_PROVIDER, minTimeMs, minDistanceM, - it, - locHandlerThread.looper, + locationCallback, + Looper.getMainLooper(), ) - } - providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - isStart.value = true - } - fun stop() { - locationListener?.let { locationManager.removeUpdates(it) } - isStart.value = false - } - - 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 + awaitClose { + locationManager.removeUpdates(locationCallback) + Log.d("Location Service", "LocationState Stop") } } } +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 { val locationNames = LruCache(20) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 08729ce67..bcdcb0998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -162,7 +162,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { fun createLiveStreamFilter(): List { val follows = account.liveDiscoveryFollowLists.value - ?.users + ?.authors ?.toList() ?.ifEmpty { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 7d0daa2c4..e3a9f050b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -211,7 +211,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { mapOf( "g" to hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .map { listOf(it.lowercase()) } .flatten(), ), limit = 100, @@ -225,7 +225,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { } fun createFollowCommunitiesFilter(): TypedFilter? { - val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null + val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null if (communitiesToLoad.isEmpty()) return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index a287f90ca..788882e92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -274,7 +274,7 @@ open class EditPostViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache - .findUsersStartingWith(lastWord.removePrefix("@")) + .findUsersStartingWith(lastWord.removePrefix("@"), account) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .reversed() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 997c3140d..1bd8df1da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fonfon.kgeohash.toGeoHash +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor 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.User import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.LocationUtil import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.ui.components.MediaCompressor @@ -78,7 +78,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID @@ -163,8 +166,7 @@ open class NewPostViewModel : ViewModel() { // GeoHash var wantsToAddGeoHash by mutableStateOf(false) - var locUtil: LocationUtil? = null - var location: Flow? = null + var location: StateFlow? = null // ZapRaiser var canAddZapRaiser by mutableStateOf(false) @@ -530,14 +532,7 @@ open class NewPostViewModel : ViewModel() { null } - val geoLocation = locUtil?.locationStateFlow?.value - val geoHash = - if (wantsToAddGeoHash && geoLocation != null) { - geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() - } else { - null - } - + val geoHash = location?.value val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null nip95attachments.forEach { @@ -1002,7 +997,7 @@ open class NewPostViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache - .findUsersStartingWith(lastWord.removePrefix("@")) + .findUsersStartingWith(lastWord.removePrefix("@"), account) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .reversed() } @@ -1031,7 +1026,7 @@ open class NewPostViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache - .findUsersStartingWith(lastWord.removePrefix("@")) + .findUsersStartingWith(lastWord.removePrefix("@"), account) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .reversed() } @@ -1059,7 +1054,7 @@ open class NewPostViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache - .findUsersStartingWith(lastWord.removePrefix("@")) + .findUsersStartingWith(lastWord.removePrefix("@"), account) .sortedWith( compareBy( { account?.isFollowing(it) }, @@ -1262,28 +1257,20 @@ open class NewPostViewModel : ViewModel() { } @OptIn(ExperimentalCoroutinesApi::class) - fun startLocation(context: Context) { - locUtil = LocationUtil(context) - locUtil?.let { + fun locationFlow(): Flow { + if (location == null) { location = - it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } - saveDraft() + Amethyst.instance.locationManager.locationStateFlow + .mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } - viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } - } - fun stopLocation() { - viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } - location = null - locUtil = null + return location!! } override fun onCleared() { super.onCleared() Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } - location = null - locUtil = null } fun toggleNIP04And24() { @@ -1386,7 +1373,7 @@ open class NewPostViewModel : ViewModel() { 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) } this.clear() this.putAll(newEntries) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt index 7177f815b..116e7a9b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddDMRelayListDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -42,6 +43,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel 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.screen.loggedIn.AccountViewModel 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.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.ammolite.relays.RelayStat @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,6 +70,7 @@ fun AddDMRelayListDialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), ) { + SetDialogToEdgeToEdge() Scaffold( topBar = { TopAppBar( @@ -114,7 +119,7 @@ fun AddDMRelayListDialog( ), verticalArrangement = Arrangement.SpaceAround, ) { - Explanation() + Explanation(postViewModel) DMRelayList(postViewModel, accountViewModel, onClose, nav) } @@ -123,7 +128,7 @@ fun AddDMRelayListDialog( } @Composable -private fun Explanation() { +private fun Explanation(postViewModel: DMRelayListViewModel) { Card(modifier = MaterialTheme.colorScheme.imageModifier) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -133,8 +138,25 @@ private fun Explanation() { Spacer(modifier = StdVertSpacer) 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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt index 0d44ce6ec..7b043c7de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AddSearchRelayListDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -42,6 +43,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel 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.screen.loggedIn.AccountViewModel 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.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.ammolite.relays.RelayStat @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,6 +70,7 @@ fun AddSearchRelayListDialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), ) { + SetDialogToEdgeToEdge() Scaffold( topBar = { TopAppBar( @@ -114,7 +119,7 @@ fun AddSearchRelayListDialog( ), verticalArrangement = Arrangement.SpaceAround, ) { - Explanation() + Explanation(postViewModel) SearchRelayList(postViewModel, accountViewModel, onClose, nav) } @@ -123,7 +128,7 @@ fun AddSearchRelayListDialog( } @Composable -private fun Explanation() { +private fun Explanation(postViewModel: SearchRelayListViewModel) { Card(modifier = MaterialTheme.colorScheme.imageModifier) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -135,6 +140,23 @@ private fun Explanation() { Text( 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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt index b033ce736..c3dce2b17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -49,6 +50,8 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel 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.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -171,91 +174,92 @@ fun MappedAllRelayListView( ) }, ) { pad -> - Column( + LazyColumn( + contentPadding = FeedPadding, modifier = Modifier .fillMaxSize() .padding( - 16.dp, - pad.calculateTopPadding(), - 16.dp, - pad.calculateBottomPadding(), - ), - verticalArrangement = Arrangement.SpaceAround, + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad), ) { - 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 { SettingsCategory( - stringRes(R.string.public_home_section), - stringRes(R.string.public_home_section_explainer), - Modifier.padding(bottom = 8.dp), + stringRes(R.string.kind_3_recommended_section), + stringRes(R.string.kind_3_recommended_section_description), ) } - 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 { - 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) - } + renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) } } } @@ -280,7 +284,20 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) { OutlinedButton( onClick = { 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() }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt index b58ec3a14..8a0b6256e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt @@ -143,7 +143,7 @@ class Kind3RelayListViewModel : ViewModel() { val proposed = RelayListRecommendationProcessor .reliableRelaySetFor( - account.liveKind3Follows.value.users.mapNotNull { + account.liveKind3Follows.value.authors.mapNotNull { account.getNIP65RelayList(it) }, relayUrlsToIgnore = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt index 8488acd82..b7384966b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableEmail 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.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav @@ -104,6 +105,7 @@ fun RelayInformationDialog( dismissOnClickOutside = false, ), ) { + SetDialogToEdgeToEdge() Surface { val color = remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index 67d38a0ae..544407858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -67,7 +67,7 @@ open class DiscoverLiveFeedFilter( override fun sort(collection: Set): List { val followingKeySet = - account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors val counter = ParticipantListBuilder() val participantCounts = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 80b5bd65b..be8680f15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( val isGlobal: Boolean, val isHiddenList: Boolean, - val followLists: Account.LiveFollowLists?, + val followLists: Account.LiveFollowList?, val hiddenLists: Account.LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), ) { @@ -45,22 +45,22 @@ class FilterByListParams( if (followLists == null) return false return if (noteEvent is LiveActivitiesEvent) { - noteEvent.participantsIntersect(followLists.users) || + noteEvent.participantsIntersect(followLists.authors) || noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.communities) + noteEvent.isTaggedAddressableNotes(followLists.addresses) } else { - noteEvent.pubKey in followLists.users || + noteEvent.pubKey in followLists.authors || noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.communities) + noteEvent.isTaggedAddressableNotes(followLists.addresses) } } fun isATagInList(aTag: ATag): Boolean { if (followLists == null) return false - return aTag.pubKeyHex in followLists.users + return aTag.pubKeyHex in followLists.authors } fun match( @@ -89,7 +89,7 @@ class FilterByListParams( fun create( userHex: String, selectedListName: String, - followLists: Account.LiveFollowLists?, + followLists: Account.LiveFollowList?, hiddenUsers: Account.LiveHiddenUsers, ): FilterByListParams = FilterByListParams( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index 2c759ec09..f1d2131cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -64,6 +64,7 @@ class GeoHashFeedFilter( it.event is AudioHeaderEvent ) && it.event?.isTaggedGeoHash(geoTag) == true && + !it.isHiddenFor(account.flowHiddenUsers.value) && account.isAcceptable(it) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 0f5c7950e..74a0b99f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -68,6 +68,7 @@ class HashtagFeedFilter( it.event is AudioHeaderEvent ) && it.event?.isTaggedHash(hashTag) == true && + !it.isHiddenFor(account.flowHiddenUsers.value) && account.isAcceptable(it) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index e9b6facaa..0351d9fb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -115,7 +115,7 @@ class NotificationFeedFilter( it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is GiftWrapEvent && (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 && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && tagsAnEventByUser(it, loggedInUserHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index a907fc9ae..2ee4d12b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -37,7 +37,7 @@ class ThreadFeedFilter( override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.users + val followingKeySet = account.liveKind3Follows.value.authors val eventsToWatch = ThreadAssembler().findThreadFor(noteId) val eventsInHex = eventsToWatch.map { it.idHex }.toSet() val now = TimeUtils.now() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 47dacdf75..6eafc0a02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage 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.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -112,7 +112,7 @@ private fun LoggedInUserPictureDrawer( fun FollowListWithRoutes( followListsModel: FollowListState, listName: String, - onChange: (CodeName) -> Unit, + onChange: (FeedDefinition) -> Unit, ) { val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() @@ -128,7 +128,7 @@ fun FollowListWithRoutes( fun FollowListWithoutRoutes( followListsModel: FollowListState, listName: String, - onChange: (CodeName) -> Unit, + onChange: (FeedDefinition) -> Unit, ) { val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 14b6ac7bb..c0b72b936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -394,7 +394,7 @@ private fun FollowingAndFollowerCounts( ) { Text( text = - followingCount.value.users.size + followingCount.value.authors.size .toString(), fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt index ee9cffda3..a1855640d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.navigation +import android.Manifest import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -33,6 +35,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -41,12 +45,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.model.AddressableNote +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation 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.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName 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.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.PeopleListEvent import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.map +@OptIn(ExperimentalPermissionsApi::class) @Composable fun FeedFilterSpinner( placeholderCode: String, explainer: String, - options: ImmutableList, + options: ImmutableList, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, ) { @@ -75,20 +90,61 @@ fun FeedFilterSpinner( id = R.string.select_an_option, ) - var currentText by + var selected by remember(placeholderCode, options) { 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( modifier = modifier, contentAlignment = Alignment.Center, ) { Row(verticalAlignment = Alignment.CenterVertically) { 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( imageVector = Icons.Default.ExpandMore, contentDescription = explainer, @@ -115,7 +171,7 @@ fun FeedFilterSpinner( options = options, onDismiss = { optionsShowing = false }, onSelect = { - currentText = options[it].name.name(context) + selected = options[it] optionsShowing = false onSelect(it) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 37c8209fe..b04a43f54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -671,13 +671,13 @@ fun LoadModerators( val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value - ?.users + ?.authors val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.users + val allFollows = accountViewModel.account.liveKind3Follows.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) @@ -724,7 +724,7 @@ private fun LoadParticipants( val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value - ?.users + ?.authors val allParticipants = ParticipantListBuilder() @@ -733,7 +733,7 @@ private fun LoadParticipants( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.users + val allFollows = accountViewModel.account.liveKind3Follows.value.authors val followingParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, allFollows) @@ -882,7 +882,7 @@ fun RenderChannelThumb( launch(Dispatchers.IO) { val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value - ?.users + ?.authors val allParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, followingKeySet) @@ -890,7 +890,7 @@ fun RenderChannelThumb( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.users + val allFollows = accountViewModel.account.liveKind3Follows.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index fd5d1651d..653872c22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable 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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -42,7 +44,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,7 +60,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -375,16 +375,7 @@ private fun RenderOptionAfterVote( QuoteBorder, ), ) { - LinearProgressIndicator( - modifier = Modifier.matchParentSize(), - color = color, - gapSize = 0.dp, - strokeCap = StrokeCap.Square, - drawStopIndicator = {}, - progress = { - poolOption.tally.value.toFloat() - }, - ) + DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize()) Row( verticalAlignment = Alignment.CenterVertically, @@ -398,10 +389,7 @@ private fun RenderOptionAfterVote( .width(45.dp) }, ) { - Text( - text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%", - fontWeight = FontWeight.Bold, - ) + TallyText(poolOption) } 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 private fun RenderOptionBeforeVote( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 8ec1f5999..ad05a333a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -46,7 +46,7 @@ data class PollOption( val option: Int, val descriptor: String, var zappedValue: MutableState = mutableStateOf(BigDecimal.ZERO), - var tally: MutableState = mutableStateOf(BigDecimal.ZERO), + var tally: MutableState = mutableStateOf(0f), var consensusThreadhold: MutableState = mutableStateOf(false), var zappedByLoggedIn: MutableState = mutableStateOf(false), ) @@ -126,7 +126,7 @@ class PollNoteViewModel : ViewModel() { } it.zappedValue.value = zappedValue - it.tally.value = tallyValue + it.tally.value = tallyValue.toFloat() it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 029feef52..722a434a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -261,7 +261,7 @@ fun UpdateZapAmountDialog( ) { Column { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index c7a431a98..a1f447424 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -373,6 +373,6 @@ fun WatchUserFollows( } else { val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() - onFollowChanges(state.users.contains(userHex)) + onFollowChanges(state.authors.contains(userHex)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 7177bfcbd..e56775327 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -144,12 +144,6 @@ fun AddInboxRelayForDMCard( Spacer(modifier = StdVertSpacer) - Text( - text = stringRes(id = R.string.dm_relays_not_found_examples), - ) - - Spacer(modifier = StdVertSpacer) - var wantsToEditRelays by remember { mutableStateOf(false) } if (wantsToEditRelays) { AddDMRelayListDialog({ wantsToEditRelays = false }, accountViewModel, nav = nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index f4331cee1..2743b646b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -355,5 +355,5 @@ fun WatchAddressableNoteFollows( ) { val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() - onFollowChanges(state.communities.contains(note.idHex)) + onFollowChanges(state.addresses.contains(note.idHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 769e782f6..10a06dd42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -21,7 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log +import androidx.compose.foundation.lazy.LazyListState 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.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -280,6 +284,8 @@ abstract class FeedViewModel( override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) + var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) + private var collectorJob: Job? = null init { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index 4dae43fc6..11f61d91b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -25,6 +25,7 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote 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.service.checkNotInMainThread 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.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.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.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -56,33 +71,56 @@ class FollowListState( val viewModelScope: CoroutineScope, ) { val kind3Follow = - CodeName( - KIND3_FOLLOWS, - ResourceName(R.string.follow_list_kind3follows), - CodeNameType.HARDCODED, + PeopleListOutBoxFeedDefinition( + code = KIND3_FOLLOWS, + name = ResourceName(R.string.follow_list_kind3follows), + 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 = + 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 = account .getAllPeopleLists() .map { - CodeName( + PeopleListOutBoxFeedDefinition( it.idHex, PeopleListName(it), CodeNameType.PEOPLE_LIST, + kinds = DEFAULT_FEED_KINDS, + listOf(it.idHex), ) }.sortedBy { it.name.name() } - val livePeopleListsFlow = MutableStateFlow(emptyList()) + val livePeopleListsFlow = MutableStateFlow(emptyList()) fun updateFeedWith(newNotes: Set) { checkNotInMainThread() @@ -118,29 +156,46 @@ class FollowListState( } @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow> = + val liveKind3FollowsFlow: Flow> = account.liveKind3Follows.transformLatest { checkNotInMainThread() val communities = - it.communities.mapNotNull { + it.addresses.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> - CodeName( + TagFeedDefinition( "Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE, + kinds = DEFAULT_COMMUNITY_FEEDS, + aTags = listOf(communityNote.idHex), + relays = account.activeGlobalRelays().toList(), ) } } val hashtags = 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 = 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( @@ -156,7 +211,7 @@ class FollowListState( checkNotInMainThread() emit( listOf( - listOf(kind3Follow, globalFollow), + listOf(kind3Follow, aroundMe, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow), @@ -172,7 +227,7 @@ class FollowListState( checkNotInMainThread() emit( listOf( - listOf(kind3Follow, globalFollow), + listOf(kind3Follow, aroundMe, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -238,8 +293,78 @@ class CommunityName( } @Immutable -data class CodeName( +abstract class FeedDefinition( val code: String, val name: Name, val type: CodeNameType, ) + +@Immutable +class GlobalFeedDefinition( + code: String, + name: Name, + type: CodeNameType, + val kinds: List, + val relays: List, +) : FeedDefinition(code, name, type) + +@Immutable +class TagFeedDefinition( + code: String, + name: Name, + type: CodeNameType, + val kinds: List, + val relays: List, + val pTags: List? = null, + val eTags: List? = null, + val aTags: List? = null, + val tTags: List? = null, + val gTags: List? = null, +) : FeedDefinition(code, name, type) + +@Immutable +class AroundMeFeedDefinition( + code: String, + name: Name, + type: CodeNameType, + val kinds: List, +) : FeedDefinition(code, name, type) + +@Immutable +class PeopleListOutBoxFeedDefinition( + code: String, + name: Name, + type: CodeNameType, + val kinds: List, + val unpackList: List, +) : 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, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1cb4bf7ff..e61eca018 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -120,7 +120,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -396,7 +395,7 @@ class AccountViewModel( note.flow().metadata.stateFlow, note.flow().reports.stateFlow, ) { hiddenUsers, followingUsers, autor, metadata, reports -> - emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users)) + emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors)) }.flowOn(Dispatchers.Default) .stateIn( viewModelScope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 7dff3251e..a72ecbf96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -188,7 +188,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.receiveAsFlow @@ -1335,21 +1334,9 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { @Composable fun DisplayLocationObserver(postViewModel: NewPostViewModel) { - val context = LocalContext.current - var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } + val location by postViewModel.locationFlow().collectAsStateWithLifecycle(null) - DisposableEffect(key1 = context) { - postViewModel.startLocation(context = context) - locationDescriptionFlow = postViewModel.location - - onDispose { postViewModel.stopLocation() } - } - - locationDescriptionFlow?.let { - val location by it.collectAsStateWithLifecycle(null) - - location?.let { DisplayLocationInTitle(geohash = it) } - } + location?.let { DisplayLocationInTitle(geohash = it) } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 71bbe24c1..becdd627c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -75,7 +75,7 @@ class SearchBarViewModel( _hashtagResults.emit(findHashtags(searchValue)) _searchResultsUsers.emit( LocalCache - .findUsersStartingWith(searchValue) + .findUsersStartingWith(searchValue, account) .sortedWith( compareBy( { it.toBestDisplayName().startsWith(searchValue, true) }, @@ -86,7 +86,7 @@ class SearchBarViewModel( ) _searchResultsNotes.emit( LocalCache - .findNotesStartingWith(searchValue) + .findNotesStartingWith(searchValue, account) .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 9c7f432d9..c6799b546 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -178,7 +177,7 @@ fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) { } } -@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) +@OptIn(FlowPreview::class) @Composable private fun SearchBar( searchBarViewModel: SearchBarViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 77350a7a8..1d827ccc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider 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.VideoEvent import com.vitorpamplona.quartz.events.WikiNoteEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -211,17 +209,15 @@ fun ThreadFeedView( accountViewModel: AccountViewModel, nav: INav, ) { - val listState = rememberLazyListState() - RefresheableBox(viewModel) { RenderFeedState( viewModel = viewModel, accountViewModel = accountViewModel, - listState = listState, + listState = viewModel.llState, nav = nav, routeForLastRead = null, onLoaded = { - RenderThreadFeed(noteId, it, listState, accountViewModel, nav) + RenderThreadFeed(noteId, it, viewModel.llState, accountViewModel, nav) }, ) } @@ -236,7 +232,6 @@ fun RenderThreadFeed( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() - val firstTimeScrolled = remember { TimeUtils.now() } LaunchedEffect(noteId, items.list) { // 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. // // 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 } + if (position >= 0) { - listState.scrollToItem(position, -200) + if (position > items.list.size - 3) { + listState.scrollToItem(position, 0) + } else { + listState.scrollToItem(position, -200) + } } } } diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 3890b63d5..0ca33446f 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -3,18 +3,23 @@ کد QR را نشانه بگیرید کد QR را نشان بده تصویر نمایه + تصویر نمایه شما اسکن کد QR بهر حال نشان بده + این پست پنهان شده چون به کلمات یا کاربران پنهان شده شما اشاره دارد این یادداشت توسط این افراد گزارش شده: این یادداشت یافت نشد + 👀 تصویر کانال رویداد مورد نظر یافت نشد پیام رمزگشایی نشد تصویر گروه محتوای نامناسب اسپم + تعداد رویدادهای اسپم که از این رله می آید جعل هویت رفتار ناهنجار + سایر ناشناس آیکن رله نویسنده ناشناس @@ -22,6 +27,9 @@ کپی شناسه نویسنده کپی شناسه یادداشت انتشار + مهر زمان و تاریخ بزن + مهر زمان: در انتظار تایید + OTS: در حال انتظار درخواست حذف/string> مسدود/گزارش @@ -29,6 +37,10 @@ گزارش جعل هویت گزارش محتوای نامناسب گزارش رفتار ناهنجار + گزارش بدافزار + گزارش مود + بدافزار + مود با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید با کلید خصوصی وارد شوید تا بتوانید یادداشت را بازنشر کنید با کلید خصوصی وارد شوید تا بتوانید یادداشت را لایک کنید @@ -42,7 +54,12 @@ دیدن تعداد بازنشر بازنشر شده + ویرایش شده + ویرایش#%1$s + اورجینال نقل قول + فورک + پیشنهاد ویرایش مبلغ جدید به ساتوشی افزودن " پاسخ به" @@ -82,6 +99,7 @@ یادداشت ها بایت خطاها + تعداد خطاهای اتصال در این نوبت خبرنامه اصلی پیام خصوصی گفتگوی عمومی @@ -100,8 +118,11 @@ آدری وبسایت آدرس لایتنینگ آدرس لایتنینگ(نقل شده) + در گالری ذخیره کن تصویر در گالری ذخیره شد تصویر ذخیره نشد + ویدئو در گالری گوشی ذخیره شد + ویدئو ذخیره نشد بارگذاری تصویر ...در حال بارگذاری این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است @@ -117,6 +138,7 @@ گفتگوها یادداشت ها پاسخ ها + گالری "دنبال شوندگان" "گزارش ها" انتخاب های بیشتر @@ -138,12 +160,15 @@ پاک کردن لوگوی اپ nsec / npub / hex کلید خصوصی + پسورد باز کردن کلید نشان دادن گذرواژه پنهان کردن گذرواژه کلید نامعتبر + کلید نامعتبر: %1$s "می پذیرم " شرایط استفاده پذیرش شرایط الازمیست + رمز عبور لازم است کلید الزامیست نام الزامی است ورود @@ -194,8 +219,18 @@ همه جدیدها را خوانده شده کن همه را خوانده شده کن بازیابی کلیدها + نکات ایمنی و ذخیره کلید + +حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. +هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. +سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. +یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. + برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. +اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. + کلید خصوصی شما رمزنگاری نشد کلید خصوصی در کلیپبورد کپی شد کلید خصوصیم را کپی کن + رمزنگاری و کپی کلید من احراز هویت انجام نشد مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند مشخصات بیومتریک نتوانست مالک این تلفن را احراز هویت کند. خطا: %1$s @@ -226,6 +261,8 @@ حذف دنبال نکردن دنبال کردن + حذف از گالری + حذف این رسانه از گالری، بعدا می توانید آن را بخوانید درخواست حذف آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. بلاک @@ -239,6 +276,7 @@ جعل هویت بدخواهانه محتوای برهنگی یا نمایان رفتار ناهنجار + نرم‌افزار خرابکار بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. یادداشت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند. گزارش سواستفاده @@ -251,6 +289,7 @@ بلاک و گزارش بلاک علامت گذاشته ها + پیش‌نویس‌ علامت گاشته های خصوصی علامت گذاشته های عمومی افزودن به علامت گذاشته های خصوصی @@ -275,6 +314,7 @@ حداقل زپ حداکثر زپ توافق نظر + (0–100)% بسته شدن پس از روز نمی توان رای داد @@ -309,18 +349,31 @@ هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود سرور فایل آدرس لایتنینگ یا @User + سرورهای رسانه + سرورهای مورد علاقه خود برای بارگزاری رسانه را انتخاب کنید. + هیچ مجموعه سرور سفارشی ندارید. می توانید از لیست امتیست استفاده کنید، یا از لیست زیر اضافه کنید↓ + سرورهای داخلی رسانه + لیست پیش فرض امتیست. می توانید تک به تک یا تمام لیست را اضافه کنید. + استفاده از لیست پیش فرض + افزودن سرور رسانه + حذف سرور رسانه رله های شما (NIP-95) فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر + گزینه های حریم خصوصی Tor/Orbot تنظیمات از طریق تنظیم Orbot متصل شوید + تنظیم + تنظیمات تور قطع اتصال از Orbot/Tor? داده های شما بلافاصله در شبکه معمولی منتقل می شوند بله خیر لیست دنبال ها همه دنبال ها + اطراف من همگانی لیست خموش + درگاه پیش فرض 9050 است ## از طریق Tor با Orbot متصل شوید \n\n1. نصب کنید [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) \n2. شروع Orbot @@ -330,6 +383,45 @@ \n6. دکمه Activate را بفشارید تا از Orbot به عنوان پراکسی استفده نمایید درگاه Orbot Socks + موتور فعال تور + استفاده از نسخه درونی یا Orbot + تنظیمات پیش فرض حریم خصوصی/ تور + تغییر سریع تمام تنظیمات زیر + رله ها / آدرس های Onion + از تور برای هر آدرس اونیون استفاده کن + رله های پیام خصوصی + تور را برای ارسال و دریافت پیام خصوصی الزامی کن + رله های غیر معتمد + تور را برای رله های ارسال / دریافت الزامی کن + رله های معتمد + تور را برای تمام رله های لیست الزامی کن + تصاویر نمایه + تور را برای بارگیری تصاویر نمایه اجباری کن + پیش نمایش URL + تور را برای بارگیری پیش نمایش آدرس الزامی کن + تصاویر + تور را برای بارگیری تصاویر الزامی کن + ویدئوها + تور را برای بارگیری ویدئوها الزامی کن + عملیات پولی + تور را برای زپ، لایتنینگ و انتقال کَشو الزامی کن + تایید آدرس ناستر + تور را برای تایید آدرس های NIP-05 الزامی کن + بارگذاری رسانه + تور را برای بارگذاری محتوا الزامی کن + داخلی + Orbot + خاموش + مقدماتی + پیش‌فرض + همه بجز رسانه + حریم خصوصی کامل + سفارشی + وقتی توسط سرور الزام شده از تور استفاده کن + IP خود را از رله های غریبه پنهان کن + IP خود را از همه چیز بجز تصاویر و ویدئوها پنهان کن + IP خود را در تمام ارتباطات پنهان کن + خودت را بساز شماره پورت نامعتبر استفاده از Orbot قطع اتصال Tor/Orbot @@ -363,6 +455,8 @@ زپ گیری تا %1$s. %2$s مانده تا هدف خواندن از رله نوشتن به رله + مقدار بایتی که به این رله فرستاده شده، شامل فیلترها و رویدادها + مقدار بایتی که از این رله فرستاده شده، شامل فیلترها و رویدادها در تلاش برای گرفتن اطلاعات رله از %1$s خطایی رخ داد مالک نگارش @@ -376,6 +470,7 @@ زبان‌ها برچسب‌ها سیاست ارسال محتوا + خطاها و هشدارهای از این رله طول پیام اشتراک فیلترها @@ -384,6 +479,7 @@ حداکثر تعداد برچسب رویداد طول محتوا حداقل PoW + احراز هویت پرداخت توکن Cashu بازپرداخت @@ -401,6 +497,7 @@ خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟ برچسب های دنبال شده رله ها + اکتشاف یادداشت بازار زنده انجمن @@ -410,14 +507,20 @@ این انجمن هیچ توضیح و قوانینی ندارد. با مالک گروه برای افزودن آن صحبت کنید. محتوای حساس هشدار محتوای حساس پیش از نمایش محتوا اضافه می کند + ترجیحات اپ تنظیمات همیشه فقط Wifi + وای فای بی اندازه هرگز + کامل + ساده + عملکرد سیستم روشن تاریک ترجیحات اپلیکیشن + اتصال کیف پول زبان پوسته پیش نمایش تصویر @@ -425,6 +528,8 @@ پیش نمایش URL مرور ژرف پنهان کردن نوار پیمایش هنگام مرور + حالت رابط کاربر + قیافه پست را انتخاب کن بارگیری تصویر اسپمر بیصدا شده. برای لغو کلیک کنید @@ -435,6 +540,7 @@ بررسی آدرس ناستر انتخاب یا لغو انتخاب همه پیش‌فرض + بازگشت به حالت پیش فرض برای ادامه یک رله انتخاب کنید باز ارسال زپ ها به: کلاینت هایی که این قابلیت را دارند زپ ها را به جای شما به LNAddress یا نمایه کاربر زیر می فرستند @@ -445,6 +551,7 @@ برای فعال سازی این حالت لازم است اماتیست یک پیغام NIP-17 بفرستد (پیغام های GiftWrapped, Sealed Direct and Group). مطمئن شوید که گیرنده از کلاینتی سازگاز استفاده می کند. فعال‌سازی عمومی + گروه خصوصی یا عمومی جدید خصوصی به موضوع @@ -460,9 +567,13 @@ پخش خودکار ویدئوها و جیف ها نشان دادن پیش نمایش URL هنگام بارگیری تصاویر + کپی استک کپی به کلیپ‌بورد + کپی کلید عمومی در کلیپبورد + اشتراک گذاری یا ذخیره‌سازی کپی URL به کلیپبورد کپی شناسه یادداشت به کلیپبورد + افزودن رسانه به گالری ایجاد شده در قوانین ورود با Amber @@ -473,6 +584,10 @@ پیام به کاربر قبول به %1$s نرسید: %2$s + آدرس NIP-11 ساخته نشد به دلیل %1$s: %2$s + به %1$s نتوانست برسد: %2$s + پاسخ %1$s تفسیر نشد: %2$s + رله درخواست %1$s را رد کرد: %2$s به %1$s نرسید: %2$s نتایج %1$s تفسیر نشد: %2$s %1$s کد %2$s را انجام نداد @@ -527,11 +642,16 @@ مشکل %1$s حل نشد. بررسی کنید که به اینترنت متصل باشید، سرور کار کند و آدرس لایتنینگ %2$s صحیح باشد. \n\nخطای %3$s صورتحساب از %1$s گرفته نشد خطا در تفسیر JSON از آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید. + خطا در تفسیر JSON از %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید URL بازخوانی در پیکربندی سرور آدرس لایتنینگ یافت نشد + فراخوانی URL از پاسخ %1$s یافت نشد خطا در تفسیر JSON از فراخوان صورتحساب آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید. + خطا در تفسیر JSON از دریافت صورتحساب %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید. مبلغ نادرست صورتحساب (%1$s ساتوشی). می بایست %3$s ساتوشی باشد. نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. کیف پول لایتنینگی گیرنده خطای روبرو را داد: %1$s + صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. المنت pr در JSON بدست آمده یافت نشد. + نمی توان صورتحساب لایتنینگ از %1$s ساخت. المنت pr در JSON بدست آمده یافت نشد. کاربر با امکان خواندن فقط هیچ واکنشی تنظیم نشده است یک اپلیکیشن UnifiedPush انتخاب کنید @@ -555,6 +675,7 @@ شرایط دسته بندی قیمت (به ساتوشی) + 1000 موقعیت مکانی شهر، استان، کشور جدید @@ -588,4 +709,79 @@ سرور پس از بارگذاری URL نداد فایل بارگذاری شده از سرور بارگیری نشد فایل محلی برای بارگذاری آماده نشد: %1$s + بارگذاری نشد: %1$s + حذف نشد: %1$s + رسانه برای NIP-95 بسیار بزرگ است + ریزتصویر بارگیری نشد + اطلاعات سربرگ آماده نشد: %1$s + فشرده سازی لغو شد + فشرده سازی فایل خروجی نساخت + کیفیت رسانه + کیفیت کم را انتخاب کنید تا رسانه را به شکل فایل کوچکتری ذخیره کند، کیفیت بالا برای ذخیره به شکل فایل بزرگتر و نافشرده برای بارگذاری رسانه بدون فشرده سازی است. + پایین + متوسط + بالا + نافشرده + ویرایش پیش‌نویس + ورود با کد QR + مسیر + خانه + جستجو + کاوش + پیغام‌ها + اعلان‌ها + سراسری + کوتاه + فیلترهای ایمنی + نوشته جدید + رسانه های کوتاه جدید: تصاویر یا ویدئوها + یادداشت جدید در انجمن + باز کردن تمام واکنش ها به این پست + بستن تمام واکنش ها به این پست + پاسخ + بازنشر یا نقل قول + پسند + زَپ + تغییر واکنش های سریع + تصویر نمایه %1$s + رله %1$s + گسترش لیست رله + گزینه های یادداشت + انتخابگر لیست رله + نظرسنجی + غیرفعال کردن نظرسنجی + صورت حساب بیتکوین + لغو صورت حساب بیتکوین + لغو فروش + زپ ریزون + لغو زپ ریزون + موقعیت مکانی + حذف موقعیت مکانی + تقسیم زپ + لغو تقسیم زپ + افزودن هشدار محتوا + حذف هشدار محتوا + کلید عمومی را به شکل کد QR نشان بده + آدرس نامعتبر + امتیست آدرسی برای باز کردن دریافت کرد ولی آدرس نامعتبر بود: %1$s + رله های صندوق پیام خصوصی + رله ها: %1$s + استفاده از رله های معمولی + تنظیم رله های صندوق ورودی خصوصی + با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید. + گزینه های خوب: +- inbox.nostr.wine (پولی) +- auth.nostr1.com (رایگان) +- you.nostr1.com (رله شخصی - پولی) + گزینه های خوب: +- inbox.nostr.wine (پولی) +- auth.nostr1.com (رایگان) +- you.nostr1.com (رله شخصی - پولی) + هم اکنون تنظیم شود + رله های جستجو + تنظیم رله های جستجو + به توسعه دهندگان رپ دهید! + متن خاموش + ارسال + پیش نویس یادداشت diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index c606943e1..a97395c19 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -540,6 +540,7 @@ Vérification de l\'adresse Nostr Tout sélectionner/désélectionner Défaut + Rétablir les valeurs par défaut Sélectionner un relay pour continuer Transférer les Zaps à: Les clients compatibles transmettront des zaps sur l\'adresse LN ou le profil Utilisateur ci-dessous au lieu du vôtre. @@ -769,6 +770,7 @@ Configurer vos relais de messagerie privée Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages. Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant) + De bonnes options sont:\n - auth.nostr1.com (gratuit)\n - inbox.nostr.wine (payant)\n - relay.0xchat.com (gratuit) 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. Configurer maintenant Relais de recherche diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index 54df0c2a2..dcb4adce7 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -539,6 +539,7 @@ A Nostr cím ellenőrzése Mind kijelölése/kijelölés visszavonása Alapértelmezett + Visszaállítás alaphelyzetbe A folytatáshoz válassz egy csomópontot Zap-ek továbbítása: 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 @@ -768,6 +769,7 @@ Állítsd be a Privát postafiókod közvetítőit 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. Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős) + Jó lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes) Adj hozzá 1–3 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é. Állítsd be most Kereső Csomópontok diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 07b133cc6..6ab29c703 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -27,10 +27,16 @@ Prijavi se s privatnim ključem za všečkanje sporočila Cashu-ja ni bilo mogoče unovčiti Na sistemu ni najdene združljive Cashu denarnice 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 + 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 Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\" Izberi \"UnifiedPush\" aplikacijo 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. + V zadovoljivem stanju + Oblačila + Rokodelstvo + Drugo + Osnutkom zapisa ni mogoče pošiljati mikro plačil (zap) Razpon ni zadovoljiv – Strežnik ne more izpolniti vrednosti, navedene v \"request’s Range header\" polju. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4bcecee7d..1b2a583ac 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -416,6 +416,7 @@ Follow List All Follows + Around Me Global Mute List @@ -635,6 +636,7 @@ Checking Nostr address Select/Deselect all Default + Reset to Defaults Select a relay to continue Forward Zaps to: @@ -929,6 +931,7 @@ Set up your Private Inbox relays This setting lets everybody know which relays to use when sending messages to you. Without them you might miss some messages. Good options are:\n - inbox.nostr.wine (paid)\n - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid) + Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Insert between 1–3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them. Set up now diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a153cbf84..d2b048574 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,23 +14,23 @@ benchmarkJunit4 = "1.3.3" biometricKtx = "1.2.0-alpha05" blurhash = "1.0.0" coil = "2.7.0" -composeBom = "2024.10.00" -coreKtx = "1.13.1" +composeBom = "2024.10.01" +coreKtx = "1.15.0" espressoCore = "3.6.1" -firebaseBom = "33.4.0" -fragmentKtx = "1.8.4" +firebaseBom = "33.5.1" +fragmentKtx = "1.8.5" gms = "4.4.2" jacksonModuleKotlin = "2.17.2" jna = "5.14.0" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.0.0" +kotlin = "2.0.20" kotlinxCollectionsImmutable = "0.3.7" kotlinxSerialization = "1.7.2" kotlinxSerializationPlugin = "2.0.0" languageId = "17.0.6" lazysodiumAndroid = "5.1.0" -lifecycleRuntimeKtx = "2.8.6" +lifecycleRuntimeKtx = "2.8.7" lightcompressor = "1.3.2" markdown = "077a2cde64" media3 = "1.4.1" @@ -53,7 +53,7 @@ zoomable = "1.6.2" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.3.0" -androidxCamera = "1.3.4" +androidxCamera = "1.4.0" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } diff --git a/icon.png b/icon.png new file mode 100644 index 000000000..dbb5d7153 Binary files /dev/null and b/icon.png differ diff --git a/quartz/build.gradle b/quartz/build.gradle index 7597e5d2b..55a32d91b 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -61,6 +61,9 @@ dependencies { implementation "com.goterl:lazysodium-android:5.1.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 api libs.jackson.module.kotlin diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/NIP44v2Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/NIP44v2Test.kt index f936c3275..beab3e00c 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/NIP44v2Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/NIP44v2Test.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.crypto.nip44 import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.nip01.Nip01 @@ -191,74 +190,3 @@ class NIP44v2Test { 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 = arrayListOf(), - @JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(), - @JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList> = arrayListOf(), - @JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList = arrayListOf(), - @JsonProperty("encrypt_decrypt_long_msg") - val encryptDecryptLongMsg: ArrayList = arrayListOf(), -) - -data class Invalid( - @JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList = arrayListOf(), - @JsonProperty("get_conversation_key") - val getConversationKey: ArrayList = arrayListOf(), - @JsonProperty("decrypt") val decrypt: ArrayList = 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 = 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, -) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/TestPackageClasses.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/TestPackageClasses.kt new file mode 100644 index 000000000..4e86da5be --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip44/TestPackageClasses.kt @@ -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 = arrayListOf(), + @JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(), + @JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList> = arrayListOf(), + @JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList = arrayListOf(), + @JsonProperty("encrypt_decrypt_long_msg") + val encryptDecryptLongMsg: ArrayList = arrayListOf(), +) + +data class Invalid( + @JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList = arrayListOf(), + @JsonProperty("get_conversation_key") + val getConversationKey: ArrayList = arrayListOf(), + @JsonProperty("decrypt") val decrypt: ArrayList = 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 = 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, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 720f8c286..360c21d17 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -119,6 +119,8 @@ class ContactListEvent( const val KIND = 3 const val ALT = "Follow List" + fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" + fun createFromScratch( followUsers: List = emptyList(), followTags: List = emptyList(), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 5968b1452..438d059b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -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 mapTaggedEvent(map: (eventId: HexKey) -> R) = mapTagged("e", map) override fun mapTaggedAddress(map: (address: String) -> R) = mapTagged("a", map) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index ea73ebae2..a014f8c6a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -121,6 +121,8 @@ interface EventInterface { fun forEachHashTag(onEach: (eventId: HexKey) -> Unit) + fun anyHashTag(onEach: (str: String) -> Boolean): Boolean + fun mapTaggedEvent(map: (eventId: HexKey) -> R): List fun mapTaggedAddress(map: (address: String) -> R): List diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index da7fca088..363cce07a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -87,6 +87,11 @@ class TextNoteEvent( tags.add(arrayOf("q", it)) } } + addresses?.forEach { + if (it.toTag() in directMentions) { + tags.add(arrayOf("q", it.toTag())) + } + } addresses ?.map { it.toTag() } ?.let {