# Conflicts:
#	gradle/libs.versions.toml
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt
This commit is contained in:
Vitor Pamplona
2025-10-03 16:46:03 -04:00
44 changed files with 588 additions and 234 deletions
+28 -17
View File
@@ -242,16 +242,16 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
Add the following line to your `commonMain` dependencies: Add the following line to your `commonMain` dependencies:
```gradle ```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:<Amethyst Version>') implementation('com.vitorpamplona.quartz:quartz:<Amethyst Version>')
``` ```
Variations to each platform are also available: Variations to each platform are also available:
```gradle ```gradle
implementation('com.github.vitorpamplona.amethyst:quartz-android:<Amethyst Version>') implementation('com.vitorpamplona.quartz:quartz-android:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-jvm:<Amethyst Version>') implementation('com.vitorpamplona.quartz:quartz-jvm:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-iosarm64:<Amethyst Version>') implementation('com.vitorpamplona.quartz:quartz-iosarm64:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-iossimulatorarm64:<Amethyst Version>') implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:<Amethyst Version>')
``` ```
### How to use ### How to use
@@ -259,24 +259,24 @@ implementation('com.github.vitorpamplona.amethyst:quartz-iossimulatorarm64:<Amet
Manage logged in users with the `KeyPair` class Manage logged in users with the `KeyPair` class
```kt ```kt
val keys = KeyPair() // creates a random key val keyPair = KeyPair() // creates a random key
val keys = KeyPair("hex...".hexToByteArray()) val keyPair = KeyPair("hex...".hexToByteArray())
val keys = KeyPair("nsec1...".bechToBytes()) val keyPair = KeyPair("nsec1...".bechToBytes())
val keys = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>")) val keyPair = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val readOnly = KeyPair(pubKey = "hex...".hexToByteArray()) val readOnly = KeyPair(pubKey = "hex...".hexToByteArray())
val readOnly = KeyPair(pubKey = "npub1...".bechToBytes()) val readOnly = KeyPair(pubKey = "npub1...".bechToBytes())
``` ```
Create signers that can be internal, when you have the private key or when it is a read-only user Create signers that can be Internal, when you have the private key or a read-only public key,
or external, when it is controlled by Amber in NIP-55 or External, when it is controlled by Amber in NIP-55.
the `NostrSignerInternal` and `NostrSignerExternal` classes. Use either the `NostrSignerInternal` or `NostrSignerExternal` class:
```kt ```kt
val signer = NostrSignerInternal(keyPair) val signer = NostrSignerInternal(keyPair)
val amberSigner = NostrSignerExternal( val amberSigner = NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(), pubKey = keyPair.pubKey.toHexKey(),
packageName = signerPackageName, packageName = signerPackageName, // Amber package name
contentResolver = appContext.contentResolver, contentResolver = appContext.contentResolver,
) )
``` ```
@@ -299,16 +299,19 @@ val client = NostrClient(socketBuilder, appScope)
If you want to auth, given a logged-in `signer`: If you want to auth, given a logged-in `signer`:
```kt ```kt
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay -> val authCoordinator = RelayAuthenticator(client, appScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer) val authedEvent = RelayAuthEvent.create(relay.url, challenge, signer)
client.sendIfExists(authedEvent, relay.url) client.sendIfExists(authedEvent, relay.url)
} }
``` ```
To manage subscriptions, the suggested approach is to use subscriptions in the Application class. To manage subscriptions, the simplest approach is to build mutable subscriptions in
the Application class. To use the best of the outbox model, this class allows you to
build filters for as many relays as needed. The `NostrClient` will connect to the
complete set of relays for all subscriptions.
```kt ```kt
val metadataSub = RelayClientSubscription( val metadataSub = NostrClientSubscription(
client = client, client = client,
filter = { filter = {
val filters = listOf( val filters = listOf(
@@ -330,6 +333,14 @@ val metadataSub = RelayClientSubscription(
} }
``` ```
In that way, you can simply call `metadataSub.updateFilter()` when you need to update
subscriptions to all relays. Or call `metadataSub.closeSubscription()` to stop the sub
without deleting it.
When your app goes to the background, you can use NostrClient's `connect` and `disconnect`
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
to `onPause` methods.
## Contributing ## Contributing
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst) Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
+8 -2
View File
@@ -151,6 +151,12 @@ android {
signingConfig = signingConfigs.debug signingConfig = signingConfigs.debug
} }
} }
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
}
}
flavorDimensions = ["channel"] flavorDimensions = ["channel"]
@@ -334,9 +340,9 @@ dependencies {
implementation libs.audiowaveform implementation libs.audiowaveform
// Video compression lib // Video compression lib
implementation libs.abedElazizShe.image.compressor implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib // Image compression lib
implementation libs.zelory.video.compressor implementation libs.zelory.image.compressor
// Cbor for cashuB format // Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor implementation libs.kotlinx.serialization.cbor
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -214,7 +214,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -266,6 +265,7 @@ class Account(
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
val kind3FollowList = FollowListState(signer, cache, scope, settings) val kind3FollowList = FollowListState(signer, cache, scope, settings)
val followSetsState = FollowSetState(signer, cache, scope)
val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer) val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer)
val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings) val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings)
@@ -317,7 +317,7 @@ class Account(
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
// Merges all follow lists to create a single All Follows feed. // Merges all follow lists to create a single All Follows feed.
val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope) val allFollows = MergedFollowListsState(kind3FollowList, followSetsState, hashtagList, geohashList, communityList, scope)
val privateDMDecryptionCache = PrivateDMCache(signer) val privateDMDecryptionCache = PrivateDMCache(signer)
val privateZapsDecryptionCache = PrivateZapCache(signer) val privateZapsDecryptionCache = PrivateZapCache(signer)
@@ -829,20 +829,6 @@ class Account(
fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic) fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic)
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile())
Log.d(this@Account.javaClass.simpleName, "Number of follow sets: ${followSetNotes.size}")
return@withContext followSetNotes
}
fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user))
suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user)) suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user))
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists package com.vitorpamplona.amethyst.model.nip51Lists.followSets
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.value import com.vitorpamplona.quartz.nip01Core.core.value
@@ -31,9 +31,9 @@ data class FollowSet(
val identifierTag: String, val identifierTag: String,
val title: String, val title: String,
val description: String?, val description: String?,
val visibility: ListVisibility, val visibility: SetVisibility,
val profileList: Set<String>, val profiles: Set<String>,
) : NostrList(listVisibility = visibility, content = profileList) { ) : NostrSet(setVisibility = visibility, content = profiles) {
companion object { companion object {
fun mapEventToSet( fun mapEventToSet(
event: PeopleListEvent, event: PeopleListEvent,
@@ -53,16 +53,16 @@ data class FollowSet(
identifierTag = dTag, identifierTag = dTag,
title = listTitle, title = listTitle,
description = listDescription, description = listDescription,
visibility = ListVisibility.Private, visibility = SetVisibility.Private,
profileList = privateFollows.toSet(), profiles = privateFollows.toSet(),
) )
} else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) { } else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) {
FollowSet( FollowSet(
identifierTag = dTag, identifierTag = dTag,
title = listTitle, title = listTitle,
description = listDescription, description = listDescription,
visibility = ListVisibility.Public, visibility = SetVisibility.Public,
profileList = publicFollows.toSet(), profiles = publicFollows.toSet(),
) )
} else { } else {
// Follow set is empty, so assume public. Why? Nostr limitation. // Follow set is empty, so assume public. Why? Nostr limitation.
@@ -71,8 +71,8 @@ data class FollowSet(
identifierTag = dTag, identifierTag = dTag,
title = listTitle, title = listTitle,
description = listDescription, description = listDescription,
visibility = ListVisibility.Public, visibility = SetVisibility.Public,
profileList = publicFollows.toSet(), profiles = publicFollows.toSet(),
) )
} }
} }
@@ -0,0 +1,95 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FollowSetState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val user = cache.getOrCreateUser(signer.pubKey)
private val isActive = MutableStateFlow(false)
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(user)
return@withContext followSetNotes
}
private fun getFollowSetNotesFlow() =
flow {
while (isActive.value) {
val followSetNotes = getFollowSetNotes()
val followSets = followSetNotes.map { mapNoteToFollowSet(it) }
emit(followSets)
delay(2000)
}
}.flowOn(Dispatchers.Default)
val profilesFlow =
getFollowSetNotesFlow()
.map { it ->
it.flatMapTo(mutableSetOf()) { it.profiles }.toSet()
}.stateIn(scope, SharingStarted.Eagerly, emptySet())
fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
fun isUserInFollowSets(user: User): Boolean = profilesFlow.value.contains(user.pubkeyHex)
init {
isActive.update { true }
scope.launch(Dispatchers.Default) {
getFollowSetNotesFlow()
.onCompletion {
isActive.update { false }
}.catch {
Log.e(this@FollowSetState.javaClass.simpleName, "Error on flow collection: ${it.message}")
isActive.update { false }
}.collect {}
}
}
}
@@ -18,15 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists package com.vitorpamplona.amethyst.model.nip51Lists.followSets
sealed class NostrList( sealed class NostrSet(
val listVisibility: ListVisibility, val setVisibility: SetVisibility,
val content: Collection<String>, val content: Collection<String>,
) )
class CuratedBookmarkList( class CuratedBookmarkSet(
val name: String, val name: String,
val visibility: ListVisibility, val visibility: SetVisibility,
val listItems: List<String>, val setItems: List<String>,
) : NostrList(visibility, listItems) ) : NostrSet(visibility, setItems)
@@ -18,9 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists package com.vitorpamplona.amethyst.model.nip51Lists.followSets
enum class ListVisibility { enum class SetVisibility {
Public, Public,
Private, Private,
Mixed, Mixed,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.serverList package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
@@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.stateIn
class MergedFollowListsState( class MergedFollowListsState(
val kind3List: FollowListState, val kind3List: FollowListState,
val followSetList: FollowSetState,
val hashtagList: HashtagListState, val hashtagList: HashtagListState,
val geohashList: GeohashListState, val geohashList: GeohashListState,
val communityList: CommunityListState, val communityList: CommunityListState,
@@ -44,12 +46,13 @@ class MergedFollowListsState(
) { ) {
fun mergeLists( fun mergeLists(
kind3: FollowListState.Kind3Follows, kind3: FollowListState.Kind3Follows,
followSetProfiles: Set<String>,
hashtags: Set<String>, hashtags: Set<String>,
geohashes: Set<String>, geohashes: Set<String>,
community: Set<CommunityTag>, community: Set<CommunityTag>,
): FollowListState.Kind3Follows = ): FollowListState.Kind3Follows =
FollowListState.Kind3Follows( FollowListState.Kind3Follows(
kind3.authors, kind3.authors + followSetProfiles,
kind3.authorsPlusMe, kind3.authorsPlusMe,
kind3.hashtags + hashtags, kind3.hashtags + hashtags,
kind3.geotags + geohashes, kind3.geotags + geohashes,
@@ -59,15 +62,17 @@ class MergedFollowListsState(
val flow: StateFlow<FollowListState.Kind3Follows> = val flow: StateFlow<FollowListState.Kind3Follows> =
combine( combine(
kind3List.flow, kind3List.flow,
followSetList.profilesFlow,
hashtagList.flow, hashtagList.flow,
geohashList.flow, geohashList.flow,
communityList.flow, communityList.flow,
) { kind3, hashtag, geohash, community -> ) { kind3, followSet, hashtag, geohash, community ->
mergeLists(kind3, hashtag, geohash, community) mergeLists(kind3, followSet, hashtag, geohash, community)
}.onStart { }.onStart {
emit( emit(
mergeLists( mergeLists(
kind3List.flow.value, kind3List.flow.value,
followSetList.profilesFlow.value,
hashtagList.flow.value, hashtagList.flow.value,
geohashList.flow.value, geohashList.flow.value,
communityList.flow.value, communityList.flow.value,
@@ -411,6 +411,10 @@ fun observeUserIsFollowing(
): State<Boolean> { ): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user. // Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user1, accountViewModel) UserFinderFilterAssemblerSubscription(user1, accountViewModel)
val isUserInFollowSets =
remember(accountViewModel.account.followSetsState) {
accountViewModel.account.followSetsState.isUserInFollowSets(user2)
}
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
val flow = val flow =
@@ -420,12 +424,14 @@ fun observeUserIsFollowing(
.follows.stateFlow .follows.stateFlow
.sample(1000) .sample(1000)
.mapLatest { userState -> .mapLatest { userState ->
userState.user.isFollowing(user2) userState.user.isFollowing(user2) || isUserInFollowSets
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
} }
return flow.collectAsStateWithLifecycle(user1.isFollowing(user2)) return flow.collectAsStateWithLifecycle(
user1.isFollowing(user2) || isUserInFollowSets,
)
} }
@SuppressLint("StateFlowValueCalledInComposition") @SuppressLint("StateFlowValueCalledInComposition")
@@ -45,6 +45,7 @@ class MediaCompressor {
contentType: String?, contentType: String?,
mediaQuality: CompressorQuality, mediaQuality: CompressorQuality,
applicationContext: Context, applicationContext: Context,
useH265: Boolean = false,
): MediaCompressorResult { ): MediaCompressorResult {
// Skip compression if user selected uncompressed // Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) { if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
@@ -57,7 +58,7 @@ class MediaCompressor {
// branch into compression based on content type // branch into compression based on content type
return when { return when {
contentType?.startsWith("video", ignoreCase = true) == true -> { contentType?.startsWith("video", ignoreCase = true) == true -> {
VideoCompressionHelper.compressVideo(uri, contentType, applicationContext, mediaQuality) VideoCompressionHelper.compressVideo(uri, contentType, applicationContext, mediaQuality, useH265)
} }
contentType?.startsWith("image", ignoreCase = true) == true && contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") && !contentType.contains("gif") &&
@@ -46,6 +46,8 @@ class MultiOrchestrator(
fun first() = list.first() fun first() = list.first()
fun hasVideo() = list.any { it.media.mimeType?.startsWith("video", ignoreCase = true) == true }
suspend fun upload( suspend fun upload(
alt: String?, alt: String?,
contentWarningReason: String?, contentWarningReason: String?,
@@ -53,6 +55,7 @@ class MultiOrchestrator(
server: ServerName, server: ServerName,
account: Account, account: Account,
context: Context, context: Context,
useH265: Boolean = false,
): Result { ): Result {
coroutineScope { coroutineScope {
val jobs = val jobs =
@@ -67,6 +70,7 @@ class MultiOrchestrator(
server, server,
account, account,
context, context,
useH265,
) )
} }
} }
@@ -85,6 +89,7 @@ class MultiOrchestrator(
server: ServerName, server: ServerName,
account: Account, account: Account,
context: Context, context: Context,
useH265: Boolean = false,
): Result { ): Result {
coroutineScope { coroutineScope {
val jobs = val jobs =
@@ -100,6 +105,7 @@ class MultiOrchestrator(
server, server,
account, account,
context, context,
useH265,
) )
} }
} }
@@ -288,9 +288,10 @@ class UploadOrchestrator {
mimeType: String?, mimeType: String?,
compressionQuality: CompressorQuality, compressionQuality: CompressorQuality,
context: Context, context: Context,
useH265: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) { ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
updateState(0.02, UploadingState.Compressing) updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext) MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265)
} else { } else {
MediaCompressorResult(uri, mimeType, null) MediaCompressorResult(uri, mimeType, null)
} }
@@ -304,8 +305,9 @@ class UploadOrchestrator {
server: ServerName, server: ServerName,
account: Account, account: Account,
context: Context, context: Context,
useH265: Boolean = false,
): UploadingFinalState { ): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
return when (server.type) { return when (server.type) {
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context) ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
@@ -324,8 +326,9 @@ class UploadOrchestrator {
server: ServerName, server: ServerName,
account: Account, account: Account,
context: Context, context: Context,
useH265: Boolean = false,
): UploadingFinalState { ): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt) val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt)
return when (server.type) { return when (server.type) {
@@ -29,6 +29,7 @@ import android.text.format.Formatter.formatFileSize
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.abedelazizshe.lightcompressorlibrary.CompressionListener import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCodec
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration import com.abedelazizshe.lightcompressorlibrary.config.Configuration
@@ -38,8 +39,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.math.roundToInt
// TODO: add Auto setting. Focus on small fast streams. 4->1080p, 1080p->720p, 720p and below stay the same resolution. Use existing matrix to determine bitrate.
data class VideoInfo( data class VideoInfo(
val resolution: VideoResolution, val resolution: VideoResolution,
val framerate: Float, val framerate: Float,
@@ -80,18 +81,26 @@ enum class VideoStandard(
override fun toString(): String = label override fun toString(): String = label
} }
private const val MBPS_TO_BPS_MULTIPLIER = 1_000_000
data class CompressionRule( data class CompressionRule(
val width: Int, val width: Int,
val height: Int, val height: Int,
val bitrateMbps: Float, val bitrateMbps: Float,
val description: String, val description: String,
) { ) {
fun getBitrateMbpsInt(framerate: Float): Int { fun getBitrateBps(
// Apply 1.5x multiplier for 60fps+ videos framerate: Float,
val multiplier = if (framerate >= 60f) 1.5f else 1.0f useH265: Boolean,
): Int {
// Apply 1.3x multiplier for 60fps+ videos, 0.7x multiplier for H265
val framerateMultiplier = if (framerate >= 60f) 1.3f else 1.0f
val codecMultiplier = if (useH265) 0.7f else 1.0f
val finalMultiplier = framerateMultiplier * codecMultiplier
// Library doesn't support float so we have to convert it to int and use 1 as minimum Log.d("VideoCompressionHelper", "framerate: $framerate, useH265: $useH265, Bitrate multiplier: $finalMultiplier")
return (bitrateMbps * multiplier).roundToInt().coerceAtLeast(1)
return (bitrateMbps * finalMultiplier * MBPS_TO_BPS_MULTIPLIER).toInt()
} }
} }
@@ -140,43 +149,33 @@ object VideoCompressionHelper {
contentType: String?, contentType: String?,
applicationContext: Context, applicationContext: Context,
mediaQuality: CompressorQuality, mediaQuality: CompressorQuality,
useH265: Boolean,
timeoutMs: Long = 60_000L, // configurable, default 60s timeoutMs: Long = 60_000L, // configurable, default 60s
): MediaCompressorResult { ): MediaCompressorResult {
val videoInfo = getVideoInfo(uri, applicationContext) val videoInfo = getVideoInfo(uri, applicationContext)
val videoBitrateInMbps = val (videoBitrateInBps, resizer) =
if (videoInfo != null) { videoInfo?.let { info ->
val bitrateMbpsInt = val rule =
compressionRules compressionRules
.getValue(mediaQuality) .getValue(mediaQuality)
.getValue(videoInfo.resolution.getStandard()) .getValue(info.resolution.getStandard())
.getBitrateMbpsInt(videoInfo.framerate)
val bitrateBps = rule.getBitrateBps(info.framerate, useH265)
Log.d(LOG_TAG, "Bitrate: ${bitrateBps}bps for ${info.resolution.getStandard()} quality=$mediaQuality framerate=${info.framerate}fps useH265=$useH265.")
Log.d( Log.d(
LOG_TAG, LOG_TAG,
"Bitrate: ${bitrateMbpsInt}Mbps for ${videoInfo.resolution.getStandard()} " + "Resizer: ${info.resolution.width}x${info.resolution.height} -> " +
"quality=$mediaQuality framerate=${videoInfo.framerate}fps.", "${rule.width}x${rule.height} (${rule.description})",
) )
} else { val resizer = VideoResizer.limitSize(rule.width.toDouble(), rule.height.toDouble())
Pair(bitrateBps, resizer)
} ?: run {
Log.w(LOG_TAG, "Video bitrate fallback: 2Mbps (videoInfo unavailable)") Log.w(LOG_TAG, "Video bitrate fallback: 2Mbps (videoInfo unavailable)")
2
}
val resizer =
if (videoInfo != null) {
val rules =
compressionRules
.getValue(mediaQuality)
.getValue(videoInfo.resolution.getStandard())
Log.d(
LOG_TAG,
"Resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> " +
"${rules.width}x${rules.height} (${rules.description})",
)
VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble())
} else {
Log.d(LOG_TAG, "Resizer: null (original resolution preserved)") Log.d(LOG_TAG, "Resizer: null (original resolution preserved)")
null Pair(2 * MBPS_TO_BPS_MULTIPLIER, null)
} }
// Get original file size safely // Get original file size safely
@@ -192,10 +191,11 @@ object VideoCompressionHelper {
storageConfiguration = AppSpecificStorageConfiguration(), storageConfiguration = AppSpecificStorageConfiguration(),
configureWith = configureWith =
Configuration( Configuration(
videoBitrateInMbps = videoBitrateInMbps, videoBitrateInBps = videoBitrateInBps.toLong(),
resizer = resizer, resizer = resizer,
videoNames = listOf(UUID.randomUUID().toString()), videoNames = listOf(UUID.randomUUID().toString()),
isMinBitrateCheckEnabled = false, isMinBitrateCheckEnabled = false,
videoCodec = if (useH265) VideoCodec.H265 else VideoCodec.H264,
), ),
listener = listener =
object : CompressionListener { object : CompressionListener {
@@ -54,7 +54,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -255,13 +254,13 @@ fun EditPostView(
postViewModel.multiOrchestrator?.let { postViewModel.multiOrchestrator?.let {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) { ) {
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -279,7 +278,7 @@ fun EditPostView(
if (lud16 != null && postViewModel.wantsInvoice) { if (lud16 != null && postViewModel.wantsInvoice) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) { ) {
InvoiceRequest( InvoiceRequest(
@@ -87,6 +87,9 @@ open class EditPostViewModel : ViewModel() {
// Images and Videos // Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
// Invoices // Invoices
var canAddInvoice by mutableStateOf(false) var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false)
@@ -201,6 +204,7 @@ open class EditPostViewModel : ViewModel() {
server, server,
myAccount, myAccount,
context, context,
useH265Codec,
) )
if (results.allGood) { if (results.allGood) {
@@ -62,6 +62,9 @@ open class NewMediaModel : ViewModel() {
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1) var mediaQualitySlider by mutableIntStateOf(1)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
open fun load( open fun load(
account: Account, account: Account,
uris: ImmutableList<SelectedMedia>, uris: ImmutableList<SelectedMedia>,
@@ -111,6 +114,7 @@ open class NewMediaModel : ViewModel() {
serverToUse, serverToUse,
myAccount, myAccount,
context, context,
useH265Codec,
) )
if (results.allGood) { if (results.allGood) {
@@ -261,4 +261,18 @@ fun ImageVideoPost(
steps = 2, steps = 2,
) )
} }
// Only show H.265 codec option if there are videos in the upload
if (postViewModel.multiOrchestrator?.hasVideo() == true) {
SettingSwitchItem(
title = R.string.video_codec_h265_label,
description = R.string.video_codec_h265_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = postViewModel.useH265Codec,
onCheckedChange = { postViewModel.useH265Codec = it },
)
}
} }
@@ -21,20 +21,20 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class FollowSetFeedFilter( class FollowSetFeedFilter(
val account: Account, val followSetState: FollowSetState,
) : FeedFilter<FollowSet>() { ) : FeedFilter<FollowSet>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-followsets" override fun feedKey(): String = followSetState.user.pubkeyHex + "-followsets"
override fun feed(): List<FollowSet> = override fun feed(): List<FollowSet> =
runBlocking(account.scope.coroutineContext) { runBlocking(followSetState.scope.coroutineContext) {
try { try {
val fetchedSets = account.getFollowSetNotes() val fetchedSets = followSetState.getFollowSetNotes()
val followSets = fetchedSets.map { account.mapNoteToFollowSet(it) } val followSets = fetchedSets.map { followSetState.mapNoteToFollowSet(it) }
println("Updated follow set size for feed filter: ${followSets.size}") println("Updated follow set size for feed filter: ${followSets.size}")
followSets followSets
} catch (e: Exception) { } catch (e: Exception) {
@@ -75,7 +75,7 @@ fun SettingSwitchItem(
text = stringRes(id = description), text = stringRes(id = description),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = Color.Gray,
maxLines = 2, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
@@ -78,7 +78,7 @@ import kotlinx.collections.immutable.toImmutableList
fun ImageVideoDescription( fun ImageVideoDescription(
uris: MultiOrchestrator, uris: MultiOrchestrator,
defaultServer: ServerName, defaultServer: ServerName,
onAdd: (String, ServerName, Boolean, Int) -> Unit, onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -91,7 +91,7 @@ fun ImageVideoDescription(
uris: MultiOrchestrator, uris: MultiOrchestrator,
defaultServer: ServerName, defaultServer: ServerName,
includeNIP95: Boolean, includeNIP95: Boolean,
onAdd: (String, ServerName, Boolean, Int) -> Unit, onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -128,6 +128,9 @@ fun ImageVideoDescription(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by remember { mutableIntStateOf(1) } var mediaQualitySlider by remember { mutableIntStateOf(1) }
// Codec selection: false = H264, true = H265
var useH265Codec by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -294,32 +297,40 @@ fun ImageVideoDescription(
} }
} }
Row( Column(horizontalAlignment = Alignment.CenterHorizontally) {
verticalAlignment = Alignment.CenterVertically, Box(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), Text(
) { text =
Column(horizontalAlignment = Alignment.CenterHorizontally) { when (mediaQualitySlider) {
Box(modifier = Modifier.fillMaxWidth()) { 0 -> stringRes(R.string.media_compression_quality_low)
Text( 1 -> stringRes(R.string.media_compression_quality_medium)
text = 2 -> stringRes(R.string.media_compression_quality_high)
when (mediaQualitySlider) { 3 -> stringRes(R.string.media_compression_quality_uncompressed)
0 -> stringRes(R.string.media_compression_quality_low) else -> stringRes(R.string.media_compression_quality_medium)
1 -> stringRes(R.string.media_compression_quality_medium) },
2 -> stringRes(R.string.media_compression_quality_high) modifier = Modifier.align(Alignment.Center),
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = mediaQualitySlider.toFloat(),
onValueChange = { mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
) )
} }
Slider(
value = mediaQualitySlider.toFloat(),
onValueChange = { mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
if (uris.first().media.isVideo() == true) {
SettingSwitchItem(
title = R.string.video_codec_h265_label,
description = R.string.video_codec_h265_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = useH265Codec,
onCheckedChange = { useH265Codec = it },
)
} }
Button( Button(
@@ -327,7 +338,7 @@ fun ImageVideoDescription(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider) }, onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
shape = QuoteBorder, shape = QuoteBorder,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
@@ -285,7 +285,7 @@ private fun GenericCommentPostBody(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -278,7 +278,7 @@ fun GroupDMScreenContent(
ImageVideoDescription( ImageVideoDescription(
selectedFiles, selectedFiles,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.uploadAndHold( postViewModel.uploadAndHold(
accountViewModel.toastManager::toast, accountViewModel.toastManager::toast,
context, context,
@@ -266,7 +266,7 @@ private fun NewProductBody(
uris = it, uris = it,
defaultServer = accountViewModel.account.settings.defaultFileServer, defaultServer = accountViewModel.account.settings.defaultFileServer,
includeNIP95 = false, includeNIP95 = false,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -326,8 +326,8 @@ private fun NewPostScreenBody(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265)
if (server.type != ServerType.NIP95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
} }
@@ -624,8 +624,9 @@ open class ShortNotePostViewModel :
server: ServerName, server: ServerName,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
useH265: Boolean,
) = try { ) = try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265)
} catch (_: SignerExceptions.ReadOnlyException) { } catch (_: SignerExceptions.ReadOnlyException) {
onError( onError(
stringRes(context, R.string.read_only_user), stringRes(context, R.string.read_only_user),
@@ -640,6 +641,7 @@ open class ShortNotePostViewModel :
server: ServerName, server: ServerName,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
useH265: Boolean,
) { ) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
@@ -654,6 +656,7 @@ open class ShortNotePostViewModel :
server, server,
account, account,
context, context,
useH265,
) )
if (results.allGood) { if (results.allGood) {
@@ -59,6 +59,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -76,10 +78,10 @@ fun ListsAndSetsScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val followSetsViewModel: NostrUserListFeedViewModel = val followSetsViewModel: FollowSetFeedViewModel =
viewModel( viewModel(
key = "NostrUserListFeedViewModel", key = "FollowSetFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account), factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
) )
ListsAndSetsScreen( ListsAndSetsScreen(
@@ -91,7 +93,7 @@ fun ListsAndSetsScreen(
@Composable @Composable
fun ListsAndSetsScreen( fun ListsAndSetsScreen(
followSetsViewModel: NostrUserListFeedViewModel, followSetsViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -117,8 +119,8 @@ fun ListsAndSetsScreen(
refresh = { refresh = {
followSetsViewModel.invalidateData() followSetsViewModel.invalidateData()
}, },
addItem = { title: String, description: String?, listType: ListVisibility -> addItem = { title: String, description: String?, listType: SetVisibility ->
val isSetPrivate = listType == ListVisibility.Private val isSetPrivate = listType == SetVisibility.Private
followSetsViewModel.addFollowSet( followSetsViewModel.addFollowSet(
setName = title, setName = title,
setDescription = description, setDescription = description,
@@ -149,9 +151,9 @@ fun ListsAndSetsScreen(
@Composable @Composable
fun CustomListsScreen( fun CustomListsScreen(
followSetState: FollowSetState, followSetFeedState: FollowSetFeedState,
refresh: () -> Unit, refresh: () -> Unit,
addItem: (title: String, description: String?, listType: ListVisibility) -> Unit, addItem: (title: String, description: String?, listType: SetVisibility) -> Unit,
openItem: (identifier: String) -> Unit, openItem: (identifier: String) -> Unit,
renameItem: (followSet: FollowSet, newName: String) -> Unit, renameItem: (followSet: FollowSet, newName: String) -> Unit,
deleteItem: (followSet: FollowSet) -> Unit, deleteItem: (followSet: FollowSet) -> Unit,
@@ -195,10 +197,10 @@ fun CustomListsScreen(
// TODO: Show components based on current tab // TODO: Show components based on current tab
FollowSetFabsAndMenu( FollowSetFabsAndMenu(
onAddPrivateSet = { name: String, description: String? -> onAddPrivateSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Private) addItem(name, description, SetVisibility.Private)
}, },
onAddPublicSet = { name: String, description: String? -> onAddPublicSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Public) addItem(name, description, SetVisibility.Public)
}, },
) )
}, },
@@ -216,7 +218,7 @@ fun CustomListsScreen(
when (page) { when (page) {
0 -> 0 ->
FollowSetFeedView( FollowSetFeedView(
followSetState = followSetState, followSetFeedState = followSetFeedState,
onRefresh = refresh, onRefresh = refresh,
onOpenItem = openItem, onOpenItem = openItem,
onRenameItem = renameItem, onRenameItem = renameItem,
@@ -410,7 +412,7 @@ private fun SetItemPreview() {
identifierTag = "00001-2222", identifierTag = "00001-2222",
title = "Sample List Title", title = "Sample List Title",
description = "Sample List Description", description = "Sample List Description",
visibility = ListVisibility.Mixed, visibility = SetVisibility.Mixed,
emptySet(), emptySet(),
) )
ThemeComparisonColumn { ThemeComparisonColumn {
@@ -55,6 +55,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -98,7 +100,7 @@ fun CustomSetItem(
selected = true, selected = true,
onClick = {}, onClick = {},
label = { label = {
Text(text = "${followSet.profileList.size}") Text(text = "${followSet.profiles.size}")
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -121,9 +123,9 @@ fun CustomSetItem(
followSet.visibility.let { followSet.visibility.let {
val text by derivedStateOf { val text by derivedStateOf {
when (it) { when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public) SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private) SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed) SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
} }
} }
Column( Column(
@@ -135,9 +137,9 @@ fun CustomSetItem(
painter = painter =
painterResource( painterResource(
when (it) { when (it) {
ListVisibility.Public -> R.drawable.ic_public SetVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock SetVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
}, },
), ),
contentDescription = stringRes(R.string.follow_set_type_description, text), contentDescription = stringRes(R.string.follow_set_type_description, text),
@@ -20,16 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
sealed class FollowSetState { import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
data object Loading : FollowSetState()
sealed class FollowSetFeedState {
data object Loading : FollowSetFeedState()
data class Loaded( data class Loaded(
val feed: List<FollowSet>, val feed: List<FollowSet>,
) : FollowSetState() ) : FollowSetFeedState()
data object Empty : FollowSetState() data object Empty : FollowSetFeedState()
data class FeedError( data class FeedError(
val errorMessage: String, val errorMessage: String,
) : FollowSetState() ) : FollowSetFeedState()
} }
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -46,17 +47,17 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable @Composable
fun FollowSetFeedView( fun FollowSetFeedView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
followSetState: FollowSetState, followSetFeedState: FollowSetFeedState,
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onOpenItem: (String) -> Unit = {}, onOpenItem: (String) -> Unit = {},
onRenameItem: (targetSet: FollowSet, newName: String) -> Unit, onRenameItem: (targetSet: FollowSet, newName: String) -> Unit,
onDeleteItem: (followSet: FollowSet) -> Unit, onDeleteItem: (followSet: FollowSet) -> Unit,
) { ) {
when (followSetState) { when (followSetFeedState) {
FollowSetState.Loading -> LoadingFeed() FollowSetFeedState.Loading -> LoadingFeed()
is FollowSetState.Loaded -> { is FollowSetFeedState.Loaded -> {
val followSetFeed = followSetState.feed val followSetFeed = followSetFeedState.feed
FollowSetLoaded( FollowSetLoaded(
loadedFeedState = followSetFeed, loadedFeedState = followSetFeed,
onRefresh = onRefresh, onRefresh = onRefresh,
@@ -66,7 +67,7 @@ fun FollowSetFeedView(
) )
} }
is FollowSetState.Empty -> { is FollowSetFeedState.Empty -> {
FollowSetFeedEmpty( FollowSetFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg), message = stringRes(R.string.follow_set_empty_feed_msg),
) { ) {
@@ -74,9 +75,9 @@ fun FollowSetFeedView(
} }
} }
is FollowSetState.FeedError -> is FollowSetFeedState.FeedError ->
FeedError( FeedError(
followSetState.errorMessage, followSetFeedState.errorMessage,
) { ) {
onRefresh() onRefresh()
} }
@@ -30,6 +30,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter
@@ -49,12 +51,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.UUID import java.util.UUID
// TODO Update: Rename this to be used only for follow sets, and create separate VMs for bookmark sets, etc. class FollowSetFeedViewModel(
class NostrUserListFeedViewModel(
val dataSource: FeedFilter<FollowSet>, val dataSource: FeedFilter<FollowSet>,
) : ViewModel(), ) : ViewModel(),
InvalidatableContent { InvalidatableContent {
private val _feedContent = MutableStateFlow<FollowSetState>(FollowSetState.Loading) private val _feedContent = MutableStateFlow<FollowSetFeedState>(FollowSetFeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
fun refresh() { fun refresh() {
@@ -67,9 +68,8 @@ class NostrUserListFeedViewModel(
noteIdentifier: String, noteIdentifier: String,
account: Account, account: Account,
): AddressableNote? { ): AddressableNote? {
// checkNotInMainThread()
val potentialNote = val potentialNote =
runBlocking(Dispatchers.IO) { account.getFollowSetNotes() } runBlocking(Dispatchers.IO) { account.followSetsState.getFollowSetNotes() }
.find { it.dTag() == noteIdentifier } .find { it.dTag() == noteIdentifier }
return potentialNote return potentialNote
} }
@@ -79,7 +79,7 @@ class NostrUserListFeedViewModel(
account: Account, account: Account,
): Boolean { ): Boolean {
val potentialNote = val potentialNote =
runBlocking(viewModelScope.coroutineContext) { account.getFollowSetNotes() } runBlocking(viewModelScope.coroutineContext) { account.followSetsState.getFollowSetNotes() }
.find { (it.event as PeopleListEvent).nameOrTitle() == setName } .find { (it.event as PeopleListEvent).nameOrTitle() == setName }
return potentialNote != null return potentialNote != null
} }
@@ -94,7 +94,7 @@ class NostrUserListFeedViewModel(
val newSets = dataSource.loadTop().toImmutableList() val newSets = dataSource.loadTop().toImmutableList()
if (oldFeedState is FollowSetState.Loaded) { if (oldFeedState is FollowSetFeedState.Loaded) {
val oldFeedList = oldFeedState.feed.toImmutableList() val oldFeedList = oldFeedState.feed.toImmutableList()
// Using size as a proxy for has changed. // Using size as a proxy for has changed.
if (!equalImmutableLists(newSets, oldFeedList)) { if (!equalImmutableLists(newSets, oldFeedList)) {
@@ -108,7 +108,7 @@ class NostrUserListFeedViewModel(
this.javaClass.simpleName, this.javaClass.simpleName,
"refreshSuspended: Error loading or refreshing feed -> ${e.message}", "refreshSuspended: Error loading or refreshing feed -> ${e.message}",
) )
_feedContent.update { FollowSetState.FeedError(e.message.toString()) } _feedContent.update { FollowSetFeedState.FeedError(e.message.toString()) }
} finally { } finally {
isRefreshing.value = false isRefreshing.value = false
} }
@@ -190,7 +190,7 @@ class NostrUserListFeedViewModel(
PeopleListEvent.addUser( PeopleListEvent.addUser(
earlierVersion = followSetEvent, earlierVersion = followSetEvent,
pubKeyHex = userProfileHex, pubKeyHex = userProfileHex,
isPrivate = followSet.visibility == ListVisibility.Private, isPrivate = followSet.visibility == SetVisibility.Private,
signer = account.signer, signer = account.signer,
) { ) {
account.sendMyPublicAndPrivateOutbox(it) account.sendMyPublicAndPrivateOutbox(it)
@@ -223,9 +223,9 @@ class NostrUserListFeedViewModel(
private fun updateFeed(sets: ImmutableList<FollowSet>) { private fun updateFeed(sets: ImmutableList<FollowSet>) {
if (sets.isNotEmpty()) { if (sets.isNotEmpty()) {
_feedContent.update { FollowSetState.Loaded(sets) } _feedContent.update { FollowSetFeedState.Loaded(sets) }
} else { } else {
_feedContent.update { FollowSetState.Empty } _feedContent.update { FollowSetFeedState.Empty }
} }
} }
@@ -244,7 +244,7 @@ class NostrUserListFeedViewModel(
init { init {
Log.d("Init", this.javaClass.simpleName) Log.d("Init", this.javaClass.simpleName)
Log.d(this.javaClass.simpleName, " FollowSetState : ${_feedContent.value}") Log.d(this.javaClass.simpleName, " FollowSetFeedState : ${_feedContent.value}")
collectorJob = collectorJob =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes -> LocalCache.live.newEventBundles.collect { newNotes ->
@@ -266,8 +266,8 @@ class NostrUserListFeedViewModel(
val account: Account, val account: Account,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
NostrUserListFeedViewModel( FollowSetFeedViewModel(
FollowSetFeedFilter(account), FollowSetFeedFilter(account.followSetsState),
) as T ) as T
} }
} }
@@ -67,14 +67,14 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -92,10 +92,10 @@ fun FollowSetScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navigator: INav, navigator: INav,
) { ) {
val followSetViewModel: NostrUserListFeedViewModel = val followSetViewModel: FollowSetFeedViewModel =
viewModel( viewModel(
key = "NostrUserListFeedViewModel", key = "FollowSetFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account), factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
) )
FollowSetScreen(selectedSetIdentifier, followSetViewModel, accountViewModel, navigator) FollowSetScreen(selectedSetIdentifier, followSetViewModel, accountViewModel, navigator)
@@ -105,7 +105,7 @@ fun FollowSetScreen(
@Composable @Composable
fun FollowSetScreen( fun FollowSetScreen(
selectedSetIdentifier: String, selectedSetIdentifier: String,
followSetViewModel: NostrUserListFeedViewModel, followSetViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navigator: INav, navigator: INav,
) { ) {
@@ -144,7 +144,7 @@ fun FollowSetScreen(
when { when {
selectedSetState.value != null -> { selectedSetState.value != null -> {
val selectedSet = selectedSetState.value val selectedSet = selectedSetState.value
val users = selectedSet!!.profileList.mapToUsers(accountViewModel).filterNotNull() val users = selectedSet!!.profiles.mapToUsers(accountViewModel).filterNotNull()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -235,10 +235,10 @@ fun TitleAndDescription(
Icon( Icon(
painter = painter =
painterResource( painterResource(
when (followSet.listVisibility) { when (followSet.setVisibility) {
ListVisibility.Public -> R.drawable.ic_public SetVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock SetVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
}, },
), ),
contentDescription = null, contentDescription = null,
@@ -78,13 +78,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetState import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListVisibility import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NewSetCreationDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NewSetCreationDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -97,10 +97,10 @@ fun FollowSetsManagementDialog(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navigator: INav, navigator: INav,
) { ) {
val followSetViewModel: NostrUserListFeedViewModel = val followSetViewModel: FollowSetFeedViewModel =
viewModel( viewModel(
key = "NostrUserListFeedViewModel", key = "FollowSetFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account), factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
) )
FollowSetsManagementDialog(userHex, followSetViewModel, accountViewModel.account, navigator) FollowSetsManagementDialog(userHex, followSetViewModel, accountViewModel.account, navigator)
@@ -110,7 +110,7 @@ fun FollowSetsManagementDialog(
@Composable @Composable
fun FollowSetsManagementDialog( fun FollowSetsManagementDialog(
userHex: String, userHex: String,
followSetsViewModel: NostrUserListFeedViewModel, followSetsViewModel: FollowSetFeedViewModel,
account: Account, account: Account,
navigator: INav, navigator: INav,
) { ) {
@@ -164,17 +164,17 @@ fun FollowSetsManagementDialog(
.imePadding(), .imePadding(),
) { ) {
when (followSetsState) { when (followSetsState) {
is FollowSetState.Loaded -> { is FollowSetFeedState.Loaded -> {
val lists = (followSetsState as FollowSetState.Loaded).feed val lists = (followSetsState as FollowSetFeedState.Loaded).feed
lists.forEachIndexed { index, list -> lists.forEachIndexed { index, list ->
Spacer(StdVertSpacer) Spacer(StdVertSpacer)
FollowSetItem( FollowSetItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
listHeader = list.title, listHeader = list.title,
listVisibility = list.visibility, setVisibility = list.visibility,
userName = userInfo.toBestDisplayName(), userName = userInfo.toBestDisplayName(),
isUserInList = list.profileList.contains(userHex), isUserInList = list.profiles.contains(userHex),
onRemoveUser = { onRemoveUser = {
Log.d( Log.d(
"Amethyst", "Amethyst",
@@ -187,7 +187,7 @@ fun FollowSetsManagementDialog(
) )
Log.d( Log.d(
"Amethyst", "Amethyst",
"Updated List. New size: ${list.profileList.size}", "Updated List. New size: ${list.profiles.size}",
) )
}, },
onAddUser = { onAddUser = {
@@ -198,28 +198,28 @@ fun FollowSetsManagementDialog(
followSetsViewModel.addUserToSet(userHex, list, account) followSetsViewModel.addUserToSet(userHex, list, account)
Log.d( Log.d(
"Amethyst", "Amethyst",
"Updated List. New size: ${list.profileList.size}", "Updated List. New size: ${list.profiles.size}",
) )
}, },
) )
} }
} }
FollowSetState.Empty -> { FollowSetFeedState.Empty -> {
EmptyOrNoneFound { followSetsViewModel.refresh() } EmptyOrNoneFound { followSetsViewModel.refresh() }
} }
is FollowSetState.FeedError -> { is FollowSetFeedState.FeedError -> {
val errorMsg = (followSetsState as FollowSetState.FeedError).errorMessage val errorMsg = (followSetsState as FollowSetFeedState.FeedError).errorMessage
ErrorMessage(errorMsg) { followSetsViewModel.refresh() } ErrorMessage(errorMsg) { followSetsViewModel.refresh() }
} }
FollowSetState.Loading -> { FollowSetFeedState.Loading -> {
Loading() Loading()
} }
} }
if (followSetsState != FollowSetState.Loading) { if (followSetsState != FollowSetFeedState.Loading) {
FollowSetsCreationMenu( FollowSetsCreationMenu(
userName = userInfo.toBestDisplayName(), userName = userInfo.toBestDisplayName(),
onSetCreate = { setName, setIsPrivate, description -> onSetCreate = { setName, setIsPrivate, description ->
@@ -304,7 +304,7 @@ private fun ErrorMessage(
fun FollowSetItem( fun FollowSetItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listHeader: String, listHeader: String,
listVisibility: ListVisibility, setVisibility: SetVisibility,
userName: String, userName: String,
isUserInList: Boolean, isUserInList: Boolean,
onAddUser: () -> Unit, onAddUser: () -> Unit,
@@ -330,21 +330,21 @@ fun FollowSetItem(
) { ) {
Text(listHeader, fontWeight = FontWeight.Bold) Text(listHeader, fontWeight = FontWeight.Bold)
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
listVisibility.let { setVisibility.let {
val text by derivedStateOf { val text by derivedStateOf {
when (it) { when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public) SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private) SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed) SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
} }
} }
Icon( Icon(
painter = painter =
painterResource( painterResource(
when (listVisibility) { when (setVisibility) {
ListVisibility.Public -> R.drawable.ic_public SetVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock SetVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
}, },
), ),
contentDescription = stringRes(R.string.follow_set_type_description, text), contentDescription = stringRes(R.string.follow_set_type_description, text),
@@ -231,7 +231,7 @@ fun PublicMessageScreenContent(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -952,4 +952,35 @@
<string name="guidelines">Pokyny</string> <string name="guidelines">Pokyny</string>
<string name="moderators">Moderátoři</string> <string name="moderators">Moderátoři</string>
<string name="open_dropdown_menu">Otevřít rozbalovací nabídku</string> <string name="open_dropdown_menu">Otevřít rozbalovací nabídku</string>
<string name="follow_sets">Sady sledování</string>
<string name="labeled_bookmarks">Označené záložky</string>
<string name="general_bookmarks">Obecné záložky</string>
<string name="follow_set_type_public">Veřejné</string>
<string name="follow_set_type_private">Soukromé</string>
<string name="follow_set_type_mixed">Smíšené</string>
<string name="follow_set_empty_feed_msg">Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_type_description">Ikona pro seznam %1$s</string>
<string name="follow_set_presence_indicator">%1$s je v tomto seznamu</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
<string name="follow_set_error_dialog_msg">Došlo k problému při načítání: %1$s</string>
<string name="follow_set_creation_menu_title">Vytvořit nový seznam</string>
<string name="follow_set_creation_item_label">Vytvořit nový seznam %1$s s uživatelem</string>
<string name="follow_set_creation_item_description">Vytvoří %1$s sadu sledování a přidá do ní %2$s.</string>
<string name="follow_set_creation_dialog_title">Nový seznam %1$s</string>
<string name="follow_set_creation_name_label">Název sady</string>
<string name="follow_set_creation_desc_label">Popis sady (volitelné)</string>
<string name="follow_set_creation_action_btn_label">Vytvořit sadu</string>
<string name="follow_set_rename_btn_label">Přejmenovat sadu</string>
<string name="follow_set_rename_dialog_indicator_first_part">Přejmenováváte z </string>
<string name="follow_set_rename_dialog_indicator_second_part"> na..</string>
<string name="rename">Přejmenovat</string>
<string name="torrent_no_info">Událost nemá dostatek informací pro vytvoření magnet odkazu</string>
<string name="my_lists_and_sets">Moje seznamy/sady</string>
<string name="select_signer">Vybrat podepisovatele</string>
<string name="video_codec_h265_label">Použít kodek H.265/HEVC</string>
<string name="video_codec_h265_description">Lepší kvalita při menší velikosti souboru, ale ne všechna zařízení podporují přehrávání H.265.</string>
</resources> </resources>
@@ -992,4 +992,35 @@ anz der Bedingungen ist erforderlich</string>
<string name="guidelines">Richtlinien</string> <string name="guidelines">Richtlinien</string>
<string name="moderators">Moderatoren</string> <string name="moderators">Moderatoren</string>
<string name="open_dropdown_menu">Dropdown-Menü öffnen</string> <string name="open_dropdown_menu">Dropdown-Menü öffnen</string>
<string name="follow_sets">Folge-Sets</string>
<string name="labeled_bookmarks">Markierte Lesezeichen</string>
<string name="general_bookmarks">Allgemeine Lesezeichen</string>
<string name="follow_set_type_public">Öffentlich</string>
<string name="follow_set_type_private">Privat</string>
<string name="follow_set_type_mixed">Gemischt</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_type_description">Symbol für %1$s-Liste</string>
<string name="follow_set_presence_indicator">%1$s ist in dieser Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
<string name="follow_set_error_dialog_msg">Beim Abrufen ist ein Problem aufgetreten: %1$s</string>
<string name="follow_set_creation_menu_title">Neue Liste erstellen</string>
<string name="follow_set_creation_item_label">Neue %1$s-Liste mit Benutzer erstellen</string>
<string name="follow_set_creation_item_description">Erstellt ein %1$s-Folge-Set und fügt %2$s hinzu.</string>
<string name="follow_set_creation_dialog_title">Neue %1$s-Liste</string>
<string name="follow_set_creation_name_label">Set-Name</string>
<string name="follow_set_creation_desc_label">Set-Beschreibung (optional)</string>
<string name="follow_set_creation_action_btn_label">Set erstellen</string>
<string name="follow_set_rename_btn_label">Set umbenennen</string>
<string name="follow_set_rename_dialog_indicator_first_part">Du benennst um von </string>
<string name="follow_set_rename_dialog_indicator_second_part"> zu..</string>
<string name="rename">Umbenennen</string>
<string name="torrent_no_info">Das Ereignis enthält nicht genügend Informationen, um einen Magnetlink zu erstellen</string>
<string name="my_lists_and_sets">Meine Listen/Sets</string>
<string name="select_signer">Signierer auswählen</string>
<string name="video_codec_h265_label">H.265/HEVC-Codec verwenden</string>
<string name="video_codec_h265_description">Bessere Qualität bei kleinerer Dateigröße, aber nicht alle Geräte unterstützen die H.265-Wiedergabe.</string>
</resources> </resources>
@@ -1065,4 +1065,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी</string>
<string name="crashreport_found_send">भेजें</string> <string name="crashreport_found_send">भेजें</string>
<string name="this_message_will_disappear_in_days">यह सन्देश %1$d दिनों में अदृश्य हो जाएगा</string> <string name="this_message_will_disappear_in_days">यह सन्देश %1$d दिनों में अदृश्य हो जाएगा</string>
<string name="select_signer">हस्ताक्षरकर्ता चुनें</string>
</resources> </resources>
@@ -112,6 +112,7 @@
<string name="post">Közzététel</string> <string name="post">Közzététel</string>
<string name="save">Mentés</string> <string name="save">Mentés</string>
<string name="create">Létrehozás</string> <string name="create">Létrehozás</string>
<string name="rename">Átnevezés</string>
<string name="cancel">Mégse</string> <string name="cancel">Mégse</string>
<string name="failed_to_upload_the_image">Nem sikerült feltölteni a képet</string> <string name="failed_to_upload_the_image">Nem sikerült feltölteni a képet</string>
<string name="relay_address">Atjátszó címe</string> <string name="relay_address">Atjátszó címe</string>
@@ -140,12 +141,12 @@
<string name="ln_address">LN-cím</string> <string name="ln_address">LN-cím</string>
<string name="ln_url_outdated">LN-webcím (elavult)</string> <string name="ln_url_outdated">LN-webcím (elavult)</string>
<string name="save_to_gallery">Mentés a galériába</string> <string name="save_to_gallery">Mentés a galériába</string>
<string name="image_saved_to_the_gallery">Kép elmentve a képgalériába</string> <string name="image_saved_to_the_gallery">Kép mentve a képgalériába</string>
<string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string> <string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string>
<string name="media_download_has_started_toast">A média letöltése megkezdődött…</string> <string name="media_download_has_started_toast">A média letöltése megkezdődött…</string>
<string name="failed_to_save_the_image">Nem sikerült menteni a képet</string> <string name="failed_to_save_the_image">Nem sikerült menteni a képet</string>
<string name="video_saved_to_the_gallery">Videó elmentve a videógalériába</string> <string name="video_saved_to_the_gallery">Videó mentve a videógalériába</string>
<string name="failed_to_save_the_video">Nem sikerült elmenteni a videót</string> <string name="failed_to_save_the_video">Nem sikerült menteni a videót</string>
<string name="upload_image">Kép feltöltése</string> <string name="upload_image">Kép feltöltése</string>
<string name="take_a_picture">Kép készítése</string> <string name="take_a_picture">Kép készítése</string>
<string name="record_a_video">Videó rögzítése</string> <string name="record_a_video">Videó rögzítése</string>
@@ -445,6 +446,33 @@
<string name="follow_list_aroundme">Közelben lévők bejegyzései</string> <string name="follow_list_aroundme">Közelben lévők bejegyzései</string>
<string name="follow_list_global">Globális</string> <string name="follow_list_global">Globális</string>
<string name="follow_list_mute_list">Némítottak bejegyzései</string> <string name="follow_list_mute_list">Némítottak bejegyzései</string>
<string name="follow_sets">Követési gyüjtemények</string>
<string name="labeled_bookmarks">Címkézett könyvjelzők</string>
<string name="general_bookmarks">Általános könyvjelzők</string>
<string name="follow_set_type_public">Nyílvános</string>
<string name="follow_set_type_private">Privát</string>
<string name="follow_set_type_mixed">Kevert</string>
<string name="follow_set_empty_feed_msg"> Úgy tűnik, hogy még egyetlen gyüjteményt sem követ.
\nKoppintson a frissítéshez vagy érintse meg a hozzáadás gombot a gyüjtemény létrehozásához.
</string>
<string name="follow_set_add_author_from_note_action">Szerző hozzáadása a követési gyűjteményhez</string>
<string name="follow_set_profile_actions_menu_description">Felhasználó hozzáadása vagy eltávolítása a listákból, vagy új lista létrehozása ezzel a felhasználóval.</string>
<string name="follow_set_type_description">Ikon a(z) %1$s nevű listához</string>
<string name="follow_set_presence_indicator">"A(z) %1$s már a létezik a listában"</string>
<string name="follow_set_absence_indicator">"A(z) %1$s nincs a listában"</string>
<string name="follow_set_man_dialog_title">Saját követési gyüjtemények</string>
<string name="follow_set_empty_dialog_msg">Nem találhatók követési gyüjtemények, vagy nincs követési gyüjteménye. Érintse meg az alábbi gombot a frissítéshez vagy használja a menüt egy gyüjtemény létrehozásához.</string>
<string name="follow_set_error_dialog_msg">Probléma történt a következő lekérdezésekor: %1$s</string>
<string name="follow_set_creation_menu_title">Új lista létrehozása</string>
<string name="follow_set_creation_item_label">Új %1$s lista létrehozása felhasználóval</string>
<string name="follow_set_creation_item_description">%1$s követési gyüjtemény létrehozása, és hozzáadás a következhöz: %2$s.</string>
<string name="follow_set_creation_dialog_title">Új %1$s lista</string>
<string name="follow_set_creation_name_label">Név megadása</string>
<string name="follow_set_creation_desc_label">Leírás megadása (nem kötelező)</string>
<string name="follow_set_creation_action_btn_label">Gyüjtemény létrehozása</string>
<string name="follow_set_rename_btn_label">Gyüjtemény átnevezése</string>
<string name="follow_set_rename_dialog_indicator_first_part">Ön átnevezi a követési gyüjteményt erről: </string>
<string name="follow_set_rename_dialog_indicator_second_part"> erre:</string>
<string name="connect_through_your_orbot_setup_short">Alapértelmezett port: 9050</string> <string name="connect_through_your_orbot_setup_short">Alapértelmezett port: 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Kapcsolódás a TORon keresztül az Orbot segítségével <string name="connect_through_your_orbot_setup_markdown"> ## Kapcsolódás a TORon keresztül az Orbot segítségével
\n\n1. Telepítse az [Orbotot](https://play.google.com/store/apps/details?id=org.torproject.android) \n\n1. Telepítse az [Orbotot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -1014,6 +1042,8 @@
<string name="torrent_download">Letöltés</string> <string name="torrent_download">Letöltés</string>
<string name="torrent_failure">Nem sikerült megnyitni a fájlt</string> <string name="torrent_failure">Nem sikerült megnyitni a fájlt</string>
<string name="torrent_no_apps">A fájl megnyitásához és letöltéséhez nincsenek torrent-alkalmazások telepítve.</string> <string name="torrent_no_apps">A fájl megnyitásához és letöltéséhez nincsenek torrent-alkalmazások telepítve.</string>
<string name="torrent_no_info">Az esemény nem tartalmaz elegendő információt a mágneshivatkozás létrehozásához</string>
<string name="my_lists_and_sets">Saját lista/gyüjtemény</string>
<string name="select_list_to_filter">Lista kiválasztása a hírfolyam szűréséhez</string> <string name="select_list_to_filter">Lista kiválasztása a hírfolyam szűréséhez</string>
<string name="temporary_account">Kijelentkeztetés az eszköz zárolása esetén</string> <string name="temporary_account">Kijelentkeztetés az eszköz zárolása esetén</string>
<string name="private_message">Privát üzenet</string> <string name="private_message">Privát üzenet</string>
@@ -1035,4 +1065,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Szeretné elküldeni a legutóbbi összeomlási jelentést az Amethystnek egy közvetlen üzenetben? A személyes adatait nem osztja meg</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Szeretné elküldeni a legutóbbi összeomlási jelentést az Amethystnek egy közvetlen üzenetben? A személyes adatait nem osztja meg</string>
<string name="crashreport_found_send">Küldés</string> <string name="crashreport_found_send">Küldés</string>
<string name="this_message_will_disappear_in_days">Ez az üzenet %1$d nap múlva eltűnik</string> <string name="this_message_will_disappear_in_days">Ez az üzenet %1$d nap múlva eltűnik</string>
<string name="select_signer">Aláíró kiválasztása</string>
</resources> </resources>
@@ -112,6 +112,7 @@
<string name="post">Wyślij</string> <string name="post">Wyślij</string>
<string name="save">Zapisz</string> <string name="save">Zapisz</string>
<string name="create">Utwórz</string> <string name="create">Utwórz</string>
<string name="rename">Zmień nazwę</string>
<string name="cancel">Anuluj</string> <string name="cancel">Anuluj</string>
<string name="failed_to_upload_the_image">Nie udało się przesłać obrazu</string> <string name="failed_to_upload_the_image">Nie udało się przesłać obrazu</string>
<string name="relay_address">Adres transmitera</string> <string name="relay_address">Adres transmitera</string>
@@ -442,6 +443,33 @@
<string name="follow_list_aroundme">W pobliżu</string> <string name="follow_list_aroundme">W pobliżu</string>
<string name="follow_list_global">Wszystkie</string> <string name="follow_list_global">Wszystkie</string>
<string name="follow_list_mute_list">Zablokowane</string> <string name="follow_list_mute_list">Zablokowane</string>
<string name="follow_sets">Zbiory obserwowanych</string>
<string name="labeled_bookmarks">Oznaczone zakładki</string>
<string name="general_bookmarks">Ogólne zakładki</string>
<string name="follow_set_type_public">Publiczna</string>
<string name="follow_set_type_private">Prywatna</string>
<string name="follow_set_type_mixed">Mieszana</string>
<string name="follow_set_empty_feed_msg"> Wygląda na to, że nie masz jeszcze żadnych zbiorów obserwowanych.
\nDotknij poniżej, aby odświeżyć, lub naciśnij przycisk Dodaj, aby utworzyć nowy.
</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do zbioru obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_type_description">Ikona dla listy %1$s</string>
<string name="follow_set_presence_indicator">"%1$s jest obecny na liście"</string>
<string name="follow_set_absence_indicator">"%1$s nie jest na liście"</string>
<string name="follow_set_man_dialog_title">Twój zbiór obserwowanych</string>
<string name="follow_set_empty_dialog_msg">Nie znaleziono zbiorów obserwowanych lub nie masz żadnych zbiorów obserwowanych. Dotknij poniżej, aby odświeżyć lub użyj menu, aby go utworzyć.</string>
<string name="follow_set_error_dialog_msg">Podczas pobierania wystąpił błąd: %1$s</string>
<string name="follow_set_creation_menu_title">Utwórz nową listę</string>
<string name="follow_set_creation_item_label">\"Utwórz nową listę %1$s z użytkownikiem</string>
<string name="follow_set_creation_item_description">Tworzy zbiór obserwowanych %1$s i dodaje do niego %2$s.</string>
<string name="follow_set_creation_dialog_title">Nowa lista %1$s</string>
<string name="follow_set_creation_name_label">Nazwa zbioru</string>
<string name="follow_set_creation_desc_label">Opis zbioru (opcjonalnie)</string>
<string name="follow_set_creation_action_btn_label">Utwórz zbiór</string>
<string name="follow_set_rename_btn_label">Zmień nazwę zbioru</string>
<string name="follow_set_rename_dialog_indicator_first_part">Zmieniasz nazwę z </string>
<string name="follow_set_rename_dialog_indicator_second_part"> do..</string>
<string name="connect_through_your_orbot_setup_short">Domyślny port to 9050</string> <string name="connect_through_your_orbot_setup_short">Domyślny port to 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Połącz przez Tor z Orbotem <string name="connect_through_your_orbot_setup_markdown"> ## Połącz przez Tor z Orbotem
\n\n1. Zainstaluj [Orbota](https://play.google. om/store/apps/details?id= org.torproject.android) \n\n1. Zainstaluj [Orbota](https://play.google. om/store/apps/details?id= org.torproject.android)
@@ -1011,6 +1039,8 @@
<string name="torrent_download">Pobierz</string> <string name="torrent_download">Pobierz</string>
<string name="torrent_failure">Nie udało się otworzyć pliku</string> <string name="torrent_failure">Nie udało się otworzyć pliku</string>
<string name="torrent_no_apps">Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku.</string> <string name="torrent_no_apps">Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku.</string>
<string name="torrent_no_info">Zdarzenie nie ma wystarczającej ilości informacji, aby zbudować link magnetyczny</string>
<string name="my_lists_and_sets">Moje Listy/Zbiory</string>
<string name="select_list_to_filter">Wybierz listę, aby filtrować aktualności</string> <string name="select_list_to_filter">Wybierz listę, aby filtrować aktualności</string>
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string> <string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
<string name="private_message">Wiadomość prywatna</string> <string name="private_message">Wiadomość prywatna</string>
@@ -1018,6 +1048,7 @@
<string name="group_relay">Transmiter Czatu</string> <string name="group_relay">Transmiter Czatu</string>
<string name="group_relay_explanation">Transmiter, z którym łączą się wszyscy użytkownicy tego czatu</string> <string name="group_relay_explanation">Transmiter, z którym łączą się wszyscy użytkownicy tego czatu</string>
<string name="share_image">Udostępnij zdjęcie…</string> <string name="share_image">Udostępnij zdjęcie…</string>
<string name="unable_to_share_image">Nie można udostępnić obrazu, spróbuj ponownie później…</string>
<string name="search_by_hashtag">Szukaj tagu: #%1$s</string> <string name="search_by_hashtag">Szukaj tagu: #%1$s</string>
<string name="dont_translate_from">Nie tłumacz z</string> <string name="dont_translate_from">Nie tłumacz z</string>
<string name="dont_translate_from_description">Języki wyświetlane tutaj nie będą tłumaczone. Wybierz język, aby usunąć go z listy języków nietłumaczonych.</string> <string name="dont_translate_from_description">Języki wyświetlane tutaj nie będą tłumaczone. Wybierz język, aby usunąć go z listy języków nietłumaczonych.</string>
@@ -1031,4 +1062,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione</string>
<string name="crashreport_found_send">Prześlij</string> <string name="crashreport_found_send">Prześlij</string>
<string name="this_message_will_disappear_in_days">Ta wiadomość zniknie za %1$d dni</string> <string name="this_message_will_disappear_in_days">Ta wiadomość zniknie za %1$d dni</string>
<string name="select_signer">Wybierz Sygnatariusza</string>
</resources> </resources>
@@ -112,6 +112,7 @@
<string name="post">Salvar</string> <string name="post">Salvar</string>
<string name="save">Salvar</string> <string name="save">Salvar</string>
<string name="create">Criar</string> <string name="create">Criar</string>
<string name="rename">Renomear</string>
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Falha ao enviar imagem</string> <string name="failed_to_upload_the_image">Falha ao enviar imagem</string>
<string name="relay_address">Endereço do Relay</string> <string name="relay_address">Endereço do Relay</string>
@@ -443,6 +444,31 @@
<string name="follow_list_aroundme">Perto de mim</string> <string name="follow_list_aroundme">Perto de mim</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista Silenciada</string> <string name="follow_list_mute_list">Lista Silenciada</string>
<string name="follow_sets">Conjuntos de Seguimento</string>
<string name="labeled_bookmarks">Favoritos com etiqueta</string>
<string name="general_bookmarks">Favoritos gerais</string>
<string name="follow_set_type_public">Público</string>
<string name="follow_set_type_private">Privado</string>
<string name="follow_set_type_mixed">Misto</string>
<string name="follow_set_empty_feed_msg">Parece que você ainda não tem conjuntos de seguimento.\nToque abaixo para atualizar ou use o botão de adicionar para criar um novo.</string>
<string name="follow_set_add_author_from_note_action">Adicionar autor ao conjunto de seguimento</string>
<string name="follow_set_profile_actions_menu_description">Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário.</string>
<string name="follow_set_type_description">Ícone da lista %1$s</string>
<string name="follow_set_presence_indicator">"%1$s está presente nesta lista"</string>
<string name="follow_set_absence_indicator">"%1$s não está nesta lista"</string>
<string name="follow_set_man_dialog_title">Seus conjuntos de seguimento</string>
<string name="follow_set_empty_dialog_msg">Nenhum conjunto de seguimento foi encontrado ou você não possui nenhum. Toque abaixo para atualizar ou use o menu para criar um.</string>
<string name="follow_set_error_dialog_msg">Houve um problema ao buscar: %1$s</string>
<string name="follow_set_creation_menu_title">Criar nova lista</string>
<string name="follow_set_creation_item_label">Criar nova lista %1$s com usuário</string>
<string name="follow_set_creation_item_description">Cria um conjunto de seguimento %1$s e adiciona %2$s a ele.</string>
<string name="follow_set_creation_dialog_title">Nova lista %1$s</string>
<string name="follow_set_creation_name_label">Nome do conjunto</string>
<string name="follow_set_creation_desc_label">Descrição do conjunto (opcional)</string>
<string name="follow_set_creation_action_btn_label">Criar conjunto</string>
<string name="follow_set_rename_btn_label">Renomear conjunto</string>
<string name="follow_set_rename_dialog_indicator_first_part">Você está renomeando de </string>
<string name="follow_set_rename_dialog_indicator_second_part"> para..</string>
<string name="connect_through_your_orbot_setup_short">Porta padrão é 9050</string> <string name="connect_through_your_orbot_setup_short">Porta padrão é 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Conecte-se através do Tor com o Orbot <string name="connect_through_your_orbot_setup_markdown"> ## Conecte-se através do Tor com o Orbot
\n\n1. Instale o [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) \n\n1. Instale o [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -830,6 +856,8 @@
<string name="media_compression_quality_medium">Média</string> <string name="media_compression_quality_medium">Média</string>
<string name="media_compression_quality_high">Alta</string> <string name="media_compression_quality_high">Alta</string>
<string name="media_compression_quality_uncompressed">Sem compressão</string> <string name="media_compression_quality_uncompressed">Sem compressão</string>
<string name="video_codec_h265_label">Usar codec H.265/HEVC</string>
<string name="video_codec_h265_description">Melhor qualidade em arquivos menores, mas nem todos os dispositivos suportam reprodução em H.265.</string>
<string name="edit_draft">Editar rascunho</string> <string name="edit_draft">Editar rascunho</string>
<string name="login_with_qr_code">Entrar com Código QR</string> <string name="login_with_qr_code">Entrar com Código QR</string>
<string name="route">Rota</string> <string name="route">Rota</string>
@@ -1012,6 +1040,8 @@
<string name="torrent_download">Baixar</string> <string name="torrent_download">Baixar</string>
<string name="torrent_failure">Falha ao abrir o arquivo</string> <string name="torrent_failure">Falha ao abrir o arquivo</string>
<string name="torrent_no_apps">Nenhum aplicativo torrent instalado para abrir e baixar o arquivo.</string> <string name="torrent_no_apps">Nenhum aplicativo torrent instalado para abrir e baixar o arquivo.</string>
<string name="torrent_no_info">O evento não tem informações suficientes para criar um link magnético</string>
<string name="my_lists_and_sets">Minhas listas/conjuntos</string>
<string name="select_list_to_filter">Selecione uma lista para filtrar o feed</string> <string name="select_list_to_filter">Selecione uma lista para filtrar o feed</string>
<string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string> <string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string>
<string name="private_message">Mensagem Privada</string> <string name="private_message">Mensagem Privada</string>
@@ -1033,4 +1063,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Gostaria de enviar o relatório de falha recente para o Amethyst em uma DM? Nenhuma informação pessoal será compartilhada</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Gostaria de enviar o relatório de falha recente para o Amethyst em uma DM? Nenhuma informação pessoal será compartilhada</string>
<string name="crashreport_found_send">Enviar</string> <string name="crashreport_found_send">Enviar</string>
<string name="this_message_will_disappear_in_days">Esta mensagem desaparecerá em %1$d dias</string> <string name="this_message_will_disappear_in_days">Esta mensagem desaparecerá em %1$d dias</string>
<string name="select_signer">Selecionar assinador</string>
</resources> </resources>
@@ -456,6 +456,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_list_aroundme">V moji okolici</string> <string name="follow_list_aroundme">V moji okolici</string>
<string name="follow_list_global">Globalno</string> <string name="follow_list_global">Globalno</string>
<string name="follow_list_mute_list">Spisek utišanih</string> <string name="follow_list_mute_list">Spisek utišanih</string>
<string name="follow_set_type_private">Zasebno</string>
<string name="follow_set_type_mixed">Mešano</string>
<string name="connect_through_your_orbot_setup_short">Prevzeta vrata so 9050</string> <string name="connect_through_your_orbot_setup_short">Prevzeta vrata so 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Poveži se preko Tor omrežja z Orbot aplikacijo <string name="connect_through_your_orbot_setup_markdown"> ## Poveži se preko Tor omrežja z Orbot aplikacijo
\n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android) \n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -112,6 +112,7 @@
<string name="post">Dela</string> <string name="post">Dela</string>
<string name="save">Spara</string> <string name="save">Spara</string>
<string name="create">Skapa</string> <string name="create">Skapa</string>
<string name="rename">Byt namn</string>
<string name="cancel">Avbryt</string> <string name="cancel">Avbryt</string>
<string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string> <string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string>
<string name="relay_address">Relä Adress</string> <string name="relay_address">Relä Adress</string>
@@ -443,6 +444,31 @@
<string name="follow_list_aroundme">Runt mig</string> <string name="follow_list_aroundme">Runt mig</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Tyst listan</string> <string name="follow_list_mute_list">Tyst listan</string>
<string name="follow_sets">Följ-set</string>
<string name="labeled_bookmarks">Märkta bokmärken</string>
<string name="general_bookmarks">Allmänna bokmärken</string>
<string name="follow_set_type_public">Offentlig</string>
<string name="follow_set_type_private">Privat</string>
<string name="follow_set_type_mixed">Blandad</string>
<string name="follow_set_empty_feed_msg">Det verkar som att du inte har några följ-set ännu.\nTryck nedan för att uppdatera eller använd plusknappen för att skapa ett nytt.</string>
<string name="follow_set_add_author_from_note_action">Lägg till författare i följ-set</string>
<string name="follow_set_profile_actions_menu_description">Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare.</string>
<string name="follow_set_type_description">Ikon för %1$s-lista</string>
<string name="follow_set_presence_indicator">"%1$s finns i denna lista"</string>
<string name="follow_set_absence_indicator">"%1$s finns inte i denna lista"</string>
<string name="follow_set_man_dialog_title">Dina följ-set</string>
<string name="follow_set_empty_dialog_msg">Inga följ-set hittades, eller så har du inga. Tryck nedan för att uppdatera eller använd menyn för att skapa ett.</string>
<string name="follow_set_error_dialog_msg">Ett problem uppstod vid hämtning: %1$s</string>
<string name="follow_set_creation_menu_title">Skapa ny lista</string>
<string name="follow_set_creation_item_label">Skapa ny %1$s-lista med användare</string>
<string name="follow_set_creation_item_description">Skapar ett %1$s-följ-set och lägger till %2$s i det.</string>
<string name="follow_set_creation_dialog_title">Ny %1$s-lista</string>
<string name="follow_set_creation_name_label">Set-namn</string>
<string name="follow_set_creation_desc_label">Set-beskrivning (valfritt)</string>
<string name="follow_set_creation_action_btn_label">Skapa set</string>
<string name="follow_set_rename_btn_label">Byt namn på set</string>
<string name="follow_set_rename_dialog_indicator_first_part">Du byter namn från </string>
<string name="follow_set_rename_dialog_indicator_second_part"> till..</string>
<string name="connect_through_your_orbot_setup_short">Standardporten är 9050</string> <string name="connect_through_your_orbot_setup_short">Standardporten är 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Anslut genom Tor med Orbot <string name="connect_through_your_orbot_setup_markdown"> ## Anslut genom Tor med Orbot
\n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) \n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -829,6 +855,8 @@
<string name="media_compression_quality_medium">Medel</string> <string name="media_compression_quality_medium">Medel</string>
<string name="media_compression_quality_high">Hög</string> <string name="media_compression_quality_high">Hög</string>
<string name="media_compression_quality_uncompressed">Okomprimerad</string> <string name="media_compression_quality_uncompressed">Okomprimerad</string>
<string name="video_codec_h265_label">Använd H.265/HEVC-codec</string>
<string name="video_codec_h265_description">Bättre kvalitet med mindre filstorlek, men inte alla enheter stöder H.265-uppspelning.</string>
<string name="edit_draft">Redigera utkast</string> <string name="edit_draft">Redigera utkast</string>
<string name="login_with_qr_code">Logga in med QR-kod</string> <string name="login_with_qr_code">Logga in med QR-kod</string>
<string name="route">Rutt</string> <string name="route">Rutt</string>
@@ -1011,6 +1039,8 @@
<string name="torrent_download">Ladda ner</string> <string name="torrent_download">Ladda ner</string>
<string name="torrent_failure">Det gick inte att öppna filen</string> <string name="torrent_failure">Det gick inte att öppna filen</string>
<string name="torrent_no_apps">Inga torrent-appar installerade för att öppna och ladda ner filen.</string> <string name="torrent_no_apps">Inga torrent-appar installerade för att öppna och ladda ner filen.</string>
<string name="torrent_no_info">Händelsen har inte tillräcklig information för att skapa en magnetlänk</string>
<string name="my_lists_and_sets">Mina listor/set</string>
<string name="select_list_to_filter">Välj en lista för att filtrera flödet</string> <string name="select_list_to_filter">Välj en lista för att filtrera flödet</string>
<string name="temporary_account">Logga ut när enheten låses</string> <string name="temporary_account">Logga ut när enheten låses</string>
<string name="private_message">Privat meddelande</string> <string name="private_message">Privat meddelande</string>
@@ -1032,4 +1062,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Vill du skicka den senaste kraschrapporten till Amethyst i ett DM? Ingen personlig information kommer att delas</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Vill du skicka den senaste kraschrapporten till Amethyst i ett DM? Ingen personlig information kommer att delas</string>
<string name="crashreport_found_send">Skicka</string> <string name="crashreport_found_send">Skicka</string>
<string name="this_message_will_disappear_in_days">Detta meddelande försvinner om %1$d dagar</string> <string name="this_message_will_disappear_in_days">Detta meddelande försvinner om %1$d dagar</string>
<string name="select_signer">Välj signatör</string>
</resources> </resources>
@@ -1065,4 +1065,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息</string> <string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息</string>
<string name="crashreport_found_send">发送它</string> <string name="crashreport_found_send">发送它</string>
<string name="this_message_will_disappear_in_days">此消息将在 %1$d 天内消失</string> <string name="this_message_will_disappear_in_days">此消息将在 %1$d 天内消失</string>
<string name="select_signer">选择签名者</string>
</resources> </resources>
+2
View File
@@ -1039,6 +1039,8 @@
<string name="media_compression_quality_medium">Medium</string> <string name="media_compression_quality_medium">Medium</string>
<string name="media_compression_quality_high">High</string> <string name="media_compression_quality_high">High</string>
<string name="media_compression_quality_uncompressed">Uncompressed</string> <string name="media_compression_quality_uncompressed">Uncompressed</string>
<string name="video_codec_h265_label">Use H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Better quality at smaller file sizes but not all devices support H.265 playback.</string>
<string name="edit_draft">Edit draft</string> <string name="edit_draft">Edit draft</string>
+3 -3
View File
@@ -33,7 +33,7 @@ languageId = "17.0.6"
lazysodiumAndroid = "5.2.0" lazysodiumAndroid = "5.2.0"
lazysodiumJava = "5.2.0" lazysodiumJava = "5.2.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.9.4"
lightcompressor = "1.3.3" lightcompressor = "1.5.0"
markdown = "f92ef49c9d" markdown = "f92ef49c9d"
media3 = "1.8.0" media3 = "1.8.0"
mockk = "1.14.5" mockk = "1.14.5"
@@ -63,7 +63,7 @@ core = "1.7.0"
mavenPublish = "0.34.0" mavenPublish = "0.34.0"
[libraries] [libraries]
abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor" }
accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" } accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -145,7 +145,7 @@ vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", v
vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" } vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" }
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" }
vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" }
zelory-video-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }