# 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
+29 -18
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:
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz:<Amethyst Version>')
```
Variations to each platform are also available:
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz-android:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-jvm:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-iosarm64:<Amethyst Version>')
implementation('com.github.vitorpamplona.amethyst:quartz-iossimulatorarm64:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-android:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-jvm:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:<Amethyst Version>')
```
### How to use
@@ -259,25 +259,25 @@ implementation('com.github.vitorpamplona.amethyst:quartz-iossimulatorarm64:<Amet
Manage logged in users with the `KeyPair` class
```kt
val keys = KeyPair() // creates a random key
val keys = KeyPair("hex...".hexToByteArray())
val keys = KeyPair("nsec1...".bechToBytes())
val keys = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val keyPair = KeyPair() // creates a random key
val keyPair = KeyPair("hex...".hexToByteArray())
val keyPair = KeyPair("nsec1...".bechToBytes())
val keyPair = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val readOnly = KeyPair(pubKey = "hex...".hexToByteArray())
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
or external, when it is controlled by Amber in NIP-55
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.
the `NostrSignerInternal` and `NostrSignerExternal` classes.
Use either the `NostrSignerInternal` or `NostrSignerExternal` class:
```kt
val signer = NostrSignerInternal(keyPair)
val amberSigner = NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = signerPackageName,
contentResolver = appContext.contentResolver,
packageName = signerPackageName, // Amber package name
contentResolver = appContext.contentResolver,
)
```
@@ -299,16 +299,19 @@ val client = NostrClient(socketBuilder, appScope)
If you want to auth, given a logged-in `signer`:
```kt
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer)
val authCoordinator = RelayAuthenticator(client, appScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relay.url, challenge, signer)
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
val metadataSub = RelayClientSubscription(
val metadataSub = NostrClientSubscription(
client = client,
filter = {
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
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
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
}
}
flavorDimensions = ["channel"]
@@ -334,9 +340,9 @@ dependencies {
implementation libs.audiowaveform
// Video compression lib
implementation libs.abedElazizShe.image.compressor
implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib
implementation libs.zelory.video.compressor
implementation libs.zelory.image.compressor
// Cbor for cashuB format
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.broadcastRelays.BroadcastRelayListDecryptionCache
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.GeohashListState
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.uploads.FileHeader
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.edits.TextNoteModificationEvent
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.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
@@ -266,6 +265,7 @@ class Account(
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
val kind3FollowList = FollowListState(signer, cache, scope, settings)
val followSetsState = FollowSetState(signer, cache, scope)
val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer)
val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings)
@@ -317,7 +317,7 @@ class Account(
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
// 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 privateZapsDecryptionCache = PrivateZapCache(signer)
@@ -829,20 +829,6 @@ class Account(
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 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
* 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 com.vitorpamplona.quartz.nip01Core.core.value
@@ -31,9 +31,9 @@ data class FollowSet(
val identifierTag: String,
val title: String,
val description: String?,
val visibility: ListVisibility,
val profileList: Set<String>,
) : NostrList(listVisibility = visibility, content = profileList) {
val visibility: SetVisibility,
val profiles: Set<String>,
) : NostrSet(setVisibility = visibility, content = profiles) {
companion object {
fun mapEventToSet(
event: PeopleListEvent,
@@ -53,16 +53,16 @@ data class FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Private,
profileList = privateFollows.toSet(),
visibility = SetVisibility.Private,
profiles = privateFollows.toSet(),
)
} else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) {
FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Public,
profileList = publicFollows.toSet(),
visibility = SetVisibility.Public,
profiles = publicFollows.toSet(),
)
} else {
// Follow set is empty, so assume public. Why? Nostr limitation.
@@ -71,8 +71,8 @@ data class FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Public,
profileList = publicFollows.toSet(),
visibility = SetVisibility.Public,
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
* 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(
val listVisibility: ListVisibility,
sealed class NostrSet(
val setVisibility: SetVisibility,
val content: Collection<String>,
)
class CuratedBookmarkList(
class CuratedBookmarkSet(
val name: String,
val visibility: ListVisibility,
val listItems: List<String>,
) : NostrList(visibility, listItems)
val visibility: SetVisibility,
val setItems: List<String>,
) : NostrSet(visibility, setItems)
@@ -18,9 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
enum class ListVisibility {
enum class SetVisibility {
Public,
Private,
Mixed,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.serverList
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.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
@@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.stateIn
class MergedFollowListsState(
val kind3List: FollowListState,
val followSetList: FollowSetState,
val hashtagList: HashtagListState,
val geohashList: GeohashListState,
val communityList: CommunityListState,
@@ -44,12 +46,13 @@ class MergedFollowListsState(
) {
fun mergeLists(
kind3: FollowListState.Kind3Follows,
followSetProfiles: Set<String>,
hashtags: Set<String>,
geohashes: Set<String>,
community: Set<CommunityTag>,
): FollowListState.Kind3Follows =
FollowListState.Kind3Follows(
kind3.authors,
kind3.authors + followSetProfiles,
kind3.authorsPlusMe,
kind3.hashtags + hashtags,
kind3.geotags + geohashes,
@@ -59,15 +62,17 @@ class MergedFollowListsState(
val flow: StateFlow<FollowListState.Kind3Follows> =
combine(
kind3List.flow,
followSetList.profilesFlow,
hashtagList.flow,
geohashList.flow,
communityList.flow,
) { kind3, hashtag, geohash, community ->
mergeLists(kind3, hashtag, geohash, community)
) { kind3, followSet, hashtag, geohash, community ->
mergeLists(kind3, followSet, hashtag, geohash, community)
}.onStart {
emit(
mergeLists(
kind3List.flow.value,
followSetList.profilesFlow.value,
hashtagList.flow.value,
geohashList.flow.value,
communityList.flow.value,
@@ -411,6 +411,10 @@ fun observeUserIsFollowing(
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
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
val flow =
@@ -420,12 +424,14 @@ fun observeUserIsFollowing(
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowing(user2)
userState.user.isFollowing(user2) || isUserInFollowSets
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user1.isFollowing(user2))
return flow.collectAsStateWithLifecycle(
user1.isFollowing(user2) || isUserInFollowSets,
)
}
@SuppressLint("StateFlowValueCalledInComposition")
@@ -45,6 +45,7 @@ class MediaCompressor {
contentType: String?,
mediaQuality: CompressorQuality,
applicationContext: Context,
useH265: Boolean = false,
): MediaCompressorResult {
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
@@ -57,7 +58,7 @@ class MediaCompressor {
// branch into compression based on content type
return when {
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.contains("gif") &&
@@ -46,6 +46,8 @@ class MultiOrchestrator(
fun first() = list.first()
fun hasVideo() = list.any { it.media.mimeType?.startsWith("video", ignoreCase = true) == true }
suspend fun upload(
alt: String?,
contentWarningReason: String?,
@@ -53,6 +55,7 @@ class MultiOrchestrator(
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
): Result {
coroutineScope {
val jobs =
@@ -67,6 +70,7 @@ class MultiOrchestrator(
server,
account,
context,
useH265,
)
}
}
@@ -85,6 +89,7 @@ class MultiOrchestrator(
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
): Result {
coroutineScope {
val jobs =
@@ -100,6 +105,7 @@ class MultiOrchestrator(
server,
account,
context,
useH265,
)
}
}
@@ -288,9 +288,10 @@ class UploadOrchestrator {
mimeType: String?,
compressionQuality: CompressorQuality,
context: Context,
useH265: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265)
} else {
MediaCompressorResult(uri, mimeType, null)
}
@@ -304,8 +305,9 @@ class UploadOrchestrator {
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
@@ -324,8 +326,9 @@ class UploadOrchestrator {
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt)
return when (server.type) {
@@ -29,6 +29,7 @@ import android.text.format.Formatter.formatFileSize
import android.util.Log
import android.widget.Toast
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCodec
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
@@ -38,8 +39,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.UUID
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(
val resolution: VideoResolution,
val framerate: Float,
@@ -80,18 +81,26 @@ enum class VideoStandard(
override fun toString(): String = label
}
private const val MBPS_TO_BPS_MULTIPLIER = 1_000_000
data class CompressionRule(
val width: Int,
val height: Int,
val bitrateMbps: Float,
val description: String,
) {
fun getBitrateMbpsInt(framerate: Float): Int {
// Apply 1.5x multiplier for 60fps+ videos
val multiplier = if (framerate >= 60f) 1.5f else 1.0f
fun getBitrateBps(
framerate: Float,
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
return (bitrateMbps * multiplier).roundToInt().coerceAtLeast(1)
Log.d("VideoCompressionHelper", "framerate: $framerate, useH265: $useH265, Bitrate multiplier: $finalMultiplier")
return (bitrateMbps * finalMultiplier * MBPS_TO_BPS_MULTIPLIER).toInt()
}
}
@@ -140,43 +149,33 @@ object VideoCompressionHelper {
contentType: String?,
applicationContext: Context,
mediaQuality: CompressorQuality,
useH265: Boolean,
timeoutMs: Long = 60_000L, // configurable, default 60s
): MediaCompressorResult {
val videoInfo = getVideoInfo(uri, applicationContext)
val videoBitrateInMbps =
if (videoInfo != null) {
val bitrateMbpsInt =
val (videoBitrateInBps, resizer) =
videoInfo?.let { info ->
val rule =
compressionRules
.getValue(mediaQuality)
.getValue(videoInfo.resolution.getStandard())
.getBitrateMbpsInt(videoInfo.framerate)
.getValue(info.resolution.getStandard())
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_TAG,
"Bitrate: ${bitrateMbpsInt}Mbps for ${videoInfo.resolution.getStandard()} " +
"quality=$mediaQuality framerate=${videoInfo.framerate}fps.",
"Resizer: ${info.resolution.width}x${info.resolution.height} -> " +
"${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)")
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)")
null
Pair(2 * MBPS_TO_BPS_MULTIPLIER, null)
}
// Get original file size safely
@@ -192,10 +191,11 @@ object VideoCompressionHelper {
storageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
videoBitrateInMbps = videoBitrateInMbps,
videoBitrateInBps = videoBitrateInBps.toLong(),
resizer = resizer,
videoNames = listOf(UUID.randomUUID().toString()),
isMinBitrateCheckEnabled = false,
videoCodec = if (useH265) VideoCodec.H265 else VideoCodec.H264,
),
listener =
object : CompressionListener {
@@ -54,7 +54,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -255,13 +254,13 @@ fun EditPostView(
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ImageVideoDescription(
it,
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)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -279,7 +278,7 @@ fun EditPostView(
if (lud16 != null && postViewModel.wantsInvoice) {
Row(
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
InvoiceRequest(
@@ -87,6 +87,9 @@ open class EditPostViewModel : ViewModel() {
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
@@ -201,6 +204,7 @@ open class EditPostViewModel : ViewModel() {
server,
myAccount,
context,
useH265Codec,
)
if (results.allGood) {
@@ -62,6 +62,9 @@ open class NewMediaModel : ViewModel() {
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
@@ -111,6 +114,7 @@ open class NewMediaModel : ViewModel() {
serverToUse,
myAccount,
context,
useH265Codec,
)
if (results.allGood) {
@@ -261,4 +261,18 @@ fun ImageVideoPost(
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
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import kotlinx.coroutines.runBlocking
class FollowSetFeedFilter(
val account: Account,
val followSetState: FollowSetState,
) : FeedFilter<FollowSet>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-followsets"
override fun feedKey(): String = followSetState.user.pubkeyHex + "-followsets"
override fun feed(): List<FollowSet> =
runBlocking(account.scope.coroutineContext) {
runBlocking(followSetState.scope.coroutineContext) {
try {
val fetchedSets = account.getFollowSetNotes()
val followSets = fetchedSets.map { account.mapNoteToFollowSet(it) }
val fetchedSets = followSetState.getFollowSetNotes()
val followSets = fetchedSets.map { followSetState.mapNoteToFollowSet(it) }
println("Updated follow set size for feed filter: ${followSets.size}")
followSets
} catch (e: Exception) {
@@ -75,7 +75,7 @@ fun SettingSwitchItem(
text = stringRes(id = description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
@@ -78,7 +78,7 @@ import kotlinx.collections.immutable.toImmutableList
fun ImageVideoDescription(
uris: MultiOrchestrator,
defaultServer: ServerName,
onAdd: (String, ServerName, Boolean, Int) -> Unit,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
@@ -91,7 +91,7 @@ fun ImageVideoDescription(
uris: MultiOrchestrator,
defaultServer: ServerName,
includeNIP95: Boolean,
onAdd: (String, ServerName, Boolean, Int) -> Unit,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
@@ -128,6 +128,9 @@ fun ImageVideoDescription(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by remember { mutableIntStateOf(1) }
// Codec selection: false = H264, true = H265
var useH265Codec by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
@@ -294,32 +297,40 @@ fun ImageVideoDescription(
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
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,
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
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,
)
}
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(
@@ -327,7 +338,7 @@ fun ImageVideoDescription(
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider) },
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
@@ -285,7 +285,7 @@ private fun GenericCommentPostBody(
ImageVideoDescription(
it,
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)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -278,7 +278,7 @@ fun GroupDMScreenContent(
ImageVideoDescription(
selectedFiles,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.uploadAndHold(
accountViewModel.toastManager::toast,
context,
@@ -266,7 +266,7 @@ private fun NewProductBody(
uris = it,
defaultServer = accountViewModel.account.settings.defaultFileServer,
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)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -326,8 +326,8 @@ private fun NewPostScreenBody(
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
@@ -624,8 +624,9 @@ open class ShortNotePostViewModel :
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
useH265: Boolean,
) = try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265)
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -640,6 +641,7 @@ open class ShortNotePostViewModel :
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
useH265: Boolean,
) {
viewModelScope.launch(Dispatchers.Default) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch
@@ -654,6 +656,7 @@ open class ShortNotePostViewModel :
server,
account,
context,
useH265,
)
if (results.allGood) {
@@ -59,6 +59,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -76,10 +78,10 @@ fun ListsAndSetsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val followSetsViewModel: NostrUserListFeedViewModel =
val followSetsViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
ListsAndSetsScreen(
@@ -91,7 +93,7 @@ fun ListsAndSetsScreen(
@Composable
fun ListsAndSetsScreen(
followSetsViewModel: NostrUserListFeedViewModel,
followSetsViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -117,8 +119,8 @@ fun ListsAndSetsScreen(
refresh = {
followSetsViewModel.invalidateData()
},
addItem = { title: String, description: String?, listType: ListVisibility ->
val isSetPrivate = listType == ListVisibility.Private
addItem = { title: String, description: String?, listType: SetVisibility ->
val isSetPrivate = listType == SetVisibility.Private
followSetsViewModel.addFollowSet(
setName = title,
setDescription = description,
@@ -149,9 +151,9 @@ fun ListsAndSetsScreen(
@Composable
fun CustomListsScreen(
followSetState: FollowSetState,
followSetFeedState: FollowSetFeedState,
refresh: () -> Unit,
addItem: (title: String, description: String?, listType: ListVisibility) -> Unit,
addItem: (title: String, description: String?, listType: SetVisibility) -> Unit,
openItem: (identifier: String) -> Unit,
renameItem: (followSet: FollowSet, newName: String) -> Unit,
deleteItem: (followSet: FollowSet) -> Unit,
@@ -195,10 +197,10 @@ fun CustomListsScreen(
// TODO: Show components based on current tab
FollowSetFabsAndMenu(
onAddPrivateSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Private)
addItem(name, description, SetVisibility.Private)
},
onAddPublicSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Public)
addItem(name, description, SetVisibility.Public)
},
)
},
@@ -216,7 +218,7 @@ fun CustomListsScreen(
when (page) {
0 ->
FollowSetFeedView(
followSetState = followSetState,
followSetFeedState = followSetFeedState,
onRefresh = refresh,
onOpenItem = openItem,
onRenameItem = renameItem,
@@ -410,7 +412,7 @@ private fun SetItemPreview() {
identifierTag = "00001-2222",
title = "Sample List Title",
description = "Sample List Description",
visibility = ListVisibility.Mixed,
visibility = SetVisibility.Mixed,
emptySet(),
)
ThemeComparisonColumn {
@@ -55,6 +55,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes
@@ -98,7 +100,7 @@ fun CustomSetItem(
selected = true,
onClick = {},
label = {
Text(text = "${followSet.profileList.size}")
Text(text = "${followSet.profiles.size}")
},
leadingIcon = {
Icon(
@@ -121,9 +123,9 @@ fun CustomSetItem(
followSet.visibility.let {
val text by derivedStateOf {
when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
}
}
Column(
@@ -135,9 +137,9 @@ fun CustomSetItem(
painter =
painterResource(
when (it) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = stringRes(R.string.follow_set_type_description, text),
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
sealed class FollowSetState {
data object Loading : FollowSetState()
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
sealed class FollowSetFeedState {
data object Loading : FollowSetFeedState()
data class Loaded(
val feed: List<FollowSet>,
) : FollowSetState()
) : FollowSetFeedState()
data object Empty : FollowSetState()
data object Empty : FollowSetFeedState()
data class FeedError(
val errorMessage: String,
) : FollowSetState()
) : FollowSetFeedState()
}
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -46,17 +47,17 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun FollowSetFeedView(
modifier: Modifier = Modifier,
followSetState: FollowSetState,
followSetFeedState: FollowSetFeedState,
onRefresh: () -> Unit = {},
onOpenItem: (String) -> Unit = {},
onRenameItem: (targetSet: FollowSet, newName: String) -> Unit,
onDeleteItem: (followSet: FollowSet) -> Unit,
) {
when (followSetState) {
FollowSetState.Loading -> LoadingFeed()
when (followSetFeedState) {
FollowSetFeedState.Loading -> LoadingFeed()
is FollowSetState.Loaded -> {
val followSetFeed = followSetState.feed
is FollowSetFeedState.Loaded -> {
val followSetFeed = followSetFeedState.feed
FollowSetLoaded(
loadedFeedState = followSetFeed,
onRefresh = onRefresh,
@@ -66,7 +67,7 @@ fun FollowSetFeedView(
)
}
is FollowSetState.Empty -> {
is FollowSetFeedState.Empty -> {
FollowSetFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg),
) {
@@ -74,9 +75,9 @@ fun FollowSetFeedView(
}
}
is FollowSetState.FeedError ->
is FollowSetFeedState.FeedError ->
FeedError(
followSetState.errorMessage,
followSetFeedState.errorMessage,
) {
onRefresh()
}
@@ -30,6 +30,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
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.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter
@@ -49,12 +51,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.UUID
// TODO Update: Rename this to be used only for follow sets, and create separate VMs for bookmark sets, etc.
class NostrUserListFeedViewModel(
class FollowSetFeedViewModel(
val dataSource: FeedFilter<FollowSet>,
) : ViewModel(),
InvalidatableContent {
private val _feedContent = MutableStateFlow<FollowSetState>(FollowSetState.Loading)
private val _feedContent = MutableStateFlow<FollowSetFeedState>(FollowSetFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
@@ -67,9 +68,8 @@ class NostrUserListFeedViewModel(
noteIdentifier: String,
account: Account,
): AddressableNote? {
// checkNotInMainThread()
val potentialNote =
runBlocking(Dispatchers.IO) { account.getFollowSetNotes() }
runBlocking(Dispatchers.IO) { account.followSetsState.getFollowSetNotes() }
.find { it.dTag() == noteIdentifier }
return potentialNote
}
@@ -79,7 +79,7 @@ class NostrUserListFeedViewModel(
account: Account,
): Boolean {
val potentialNote =
runBlocking(viewModelScope.coroutineContext) { account.getFollowSetNotes() }
runBlocking(viewModelScope.coroutineContext) { account.followSetsState.getFollowSetNotes() }
.find { (it.event as PeopleListEvent).nameOrTitle() == setName }
return potentialNote != null
}
@@ -94,7 +94,7 @@ class NostrUserListFeedViewModel(
val newSets = dataSource.loadTop().toImmutableList()
if (oldFeedState is FollowSetState.Loaded) {
if (oldFeedState is FollowSetFeedState.Loaded) {
val oldFeedList = oldFeedState.feed.toImmutableList()
// Using size as a proxy for has changed.
if (!equalImmutableLists(newSets, oldFeedList)) {
@@ -108,7 +108,7 @@ class NostrUserListFeedViewModel(
this.javaClass.simpleName,
"refreshSuspended: Error loading or refreshing feed -> ${e.message}",
)
_feedContent.update { FollowSetState.FeedError(e.message.toString()) }
_feedContent.update { FollowSetFeedState.FeedError(e.message.toString()) }
} finally {
isRefreshing.value = false
}
@@ -190,7 +190,7 @@ class NostrUserListFeedViewModel(
PeopleListEvent.addUser(
earlierVersion = followSetEvent,
pubKeyHex = userProfileHex,
isPrivate = followSet.visibility == ListVisibility.Private,
isPrivate = followSet.visibility == SetVisibility.Private,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
@@ -223,9 +223,9 @@ class NostrUserListFeedViewModel(
private fun updateFeed(sets: ImmutableList<FollowSet>) {
if (sets.isNotEmpty()) {
_feedContent.update { FollowSetState.Loaded(sets) }
_feedContent.update { FollowSetFeedState.Loaded(sets) }
} else {
_feedContent.update { FollowSetState.Empty }
_feedContent.update { FollowSetFeedState.Empty }
}
}
@@ -244,7 +244,7 @@ class NostrUserListFeedViewModel(
init {
Log.d("Init", this.javaClass.simpleName)
Log.d(this.javaClass.simpleName, " FollowSetState : ${_feedContent.value}")
Log.d(this.javaClass.simpleName, " FollowSetFeedState : ${_feedContent.value}")
collectorJob =
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
@@ -266,8 +266,8 @@ class NostrUserListFeedViewModel(
val account: Account,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NostrUserListFeedViewModel(
FollowSetFeedFilter(account),
FollowSetFeedViewModel(
FollowSetFeedFilter(account.followSetsState),
) as T
}
}
@@ -67,14 +67,14 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
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.ListVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -92,10 +92,10 @@ fun FollowSetScreen(
accountViewModel: AccountViewModel,
navigator: INav,
) {
val followSetViewModel: NostrUserListFeedViewModel =
val followSetViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
FollowSetScreen(selectedSetIdentifier, followSetViewModel, accountViewModel, navigator)
@@ -105,7 +105,7 @@ fun FollowSetScreen(
@Composable
fun FollowSetScreen(
selectedSetIdentifier: String,
followSetViewModel: NostrUserListFeedViewModel,
followSetViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel,
navigator: INav,
) {
@@ -144,7 +144,7 @@ fun FollowSetScreen(
when {
selectedSetState.value != null -> {
val selectedSet = selectedSetState.value
val users = selectedSet!!.profileList.mapToUsers(accountViewModel).filterNotNull()
val users = selectedSet!!.profiles.mapToUsers(accountViewModel).filterNotNull()
Scaffold(
topBar = {
TopAppBar(
@@ -235,10 +235,10 @@ fun TitleAndDescription(
Icon(
painter =
painterResource(
when (followSet.listVisibility) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
when (followSet.setVisibility) {
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = null,
@@ -78,13 +78,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
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.note.ArrowBackIcon
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.ListVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedState
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.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -97,10 +97,10 @@ fun FollowSetsManagementDialog(
accountViewModel: AccountViewModel,
navigator: INav,
) {
val followSetViewModel: NostrUserListFeedViewModel =
val followSetViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
FollowSetsManagementDialog(userHex, followSetViewModel, accountViewModel.account, navigator)
@@ -110,7 +110,7 @@ fun FollowSetsManagementDialog(
@Composable
fun FollowSetsManagementDialog(
userHex: String,
followSetsViewModel: NostrUserListFeedViewModel,
followSetsViewModel: FollowSetFeedViewModel,
account: Account,
navigator: INav,
) {
@@ -164,17 +164,17 @@ fun FollowSetsManagementDialog(
.imePadding(),
) {
when (followSetsState) {
is FollowSetState.Loaded -> {
val lists = (followSetsState as FollowSetState.Loaded).feed
is FollowSetFeedState.Loaded -> {
val lists = (followSetsState as FollowSetFeedState.Loaded).feed
lists.forEachIndexed { index, list ->
Spacer(StdVertSpacer)
FollowSetItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
listVisibility = list.visibility,
setVisibility = list.visibility,
userName = userInfo.toBestDisplayName(),
isUserInList = list.profileList.contains(userHex),
isUserInList = list.profiles.contains(userHex),
onRemoveUser = {
Log.d(
"Amethyst",
@@ -187,7 +187,7 @@ fun FollowSetsManagementDialog(
)
Log.d(
"Amethyst",
"Updated List. New size: ${list.profileList.size}",
"Updated List. New size: ${list.profiles.size}",
)
},
onAddUser = {
@@ -198,28 +198,28 @@ fun FollowSetsManagementDialog(
followSetsViewModel.addUserToSet(userHex, list, account)
Log.d(
"Amethyst",
"Updated List. New size: ${list.profileList.size}",
"Updated List. New size: ${list.profiles.size}",
)
},
)
}
}
FollowSetState.Empty -> {
FollowSetFeedState.Empty -> {
EmptyOrNoneFound { followSetsViewModel.refresh() }
}
is FollowSetState.FeedError -> {
val errorMsg = (followSetsState as FollowSetState.FeedError).errorMessage
is FollowSetFeedState.FeedError -> {
val errorMsg = (followSetsState as FollowSetFeedState.FeedError).errorMessage
ErrorMessage(errorMsg) { followSetsViewModel.refresh() }
}
FollowSetState.Loading -> {
FollowSetFeedState.Loading -> {
Loading()
}
}
if (followSetsState != FollowSetState.Loading) {
if (followSetsState != FollowSetFeedState.Loading) {
FollowSetsCreationMenu(
userName = userInfo.toBestDisplayName(),
onSetCreate = { setName, setIsPrivate, description ->
@@ -304,7 +304,7 @@ private fun ErrorMessage(
fun FollowSetItem(
modifier: Modifier = Modifier,
listHeader: String,
listVisibility: ListVisibility,
setVisibility: SetVisibility,
userName: String,
isUserInList: Boolean,
onAddUser: () -> Unit,
@@ -330,21 +330,21 @@ fun FollowSetItem(
) {
Text(listHeader, fontWeight = FontWeight.Bold)
Spacer(modifier = StdHorzSpacer)
listVisibility.let {
setVisibility.let {
val text by derivedStateOf {
when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
}
}
Icon(
painter =
painterResource(
when (listVisibility) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
when (setVisibility) {
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = stringRes(R.string.follow_set_type_description, text),
@@ -231,7 +231,7 @@ fun PublicMessageScreenContent(
ImageVideoDescription(
it,
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)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -952,4 +952,35 @@
<string name="guidelines">Pokyny</string>
<string name="moderators">Moderátoři</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>
@@ -992,4 +992,35 @@ anz der Bedingungen ist erforderlich</string>
<string name="guidelines">Richtlinien</string>
<string name="moderators">Moderatoren</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>
@@ -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="crashreport_found_send">भेजें</string>
<string name="this_message_will_disappear_in_days">यह सन्देश %1$d दिनों में अदृश्य हो जाएगा</string>
<string name="select_signer">हस्ताक्षरकर्ता चुनें</string>
</resources>
@@ -112,6 +112,7 @@
<string name="post">Közzététel</string>
<string name="save">Mentés</string>
<string name="create">Létrehozás</string>
<string name="rename">Átnevezés</string>
<string name="cancel">Mégse</string>
<string name="failed_to_upload_the_image">Nem sikerült feltölteni a képet</string>
<string name="relay_address">Atjátszó címe</string>
@@ -140,12 +141,12 @@
<string name="ln_address">LN-cím</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="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="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="video_saved_to_the_gallery">Videó elmentve a videógalériába</string>
<string name="failed_to_save_the_video">Nem sikerült elmenteni a videót</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 menteni a videót</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="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_global">Globális</string>
<string name="follow_list_mute_list">Némítottak bejegyzései</string>
<string name="follow_sets">Követési gyüjtemények</string>
<string name="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_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)
@@ -1014,6 +1042,8 @@
<string name="torrent_download">Letöltés</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_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="temporary_account">Kijelentkeztetés az eszköz zárolása esetén</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="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="select_signer">Aláíró kiválasztása</string>
</resources>
@@ -112,6 +112,7 @@
<string name="post">Wyślij</string>
<string name="save">Zapisz</string>
<string name="create">Utwórz</string>
<string name="rename">Zmień nazwę</string>
<string name="cancel">Anuluj</string>
<string name="failed_to_upload_the_image">Nie udało się przesłać obrazu</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_global">Wszystkie</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_markdown"> ## Połącz przez Tor z Orbotem
\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_failure">Nie udało się otworzyć pliku</string>
<string name="torrent_no_apps">Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku.</string>
<string name="torrent_no_info">Zdarzenie nie ma wystarczającej ilości informacji, aby zbudować link magnetyczny</string>
<string name="my_lists_and_sets">Moje Listy/Zbiory</string>
<string name="select_list_to_filter">Wybierz listę, aby filtrować aktualności</string>
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
<string name="private_message">Wiadomość prywatna</string>
@@ -1018,6 +1048,7 @@
<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="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="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>
@@ -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="crashreport_found_send">Prześlij</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>
@@ -112,6 +112,7 @@
<string name="post">Salvar</string>
<string name="save">Salvar</string>
<string name="create">Criar</string>
<string name="rename">Renomear</string>
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Falha ao enviar imagem</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_global">Global</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_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)
@@ -830,6 +856,8 @@
<string name="media_compression_quality_medium">Média</string>
<string name="media_compression_quality_high">Alta</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="login_with_qr_code">Entrar com Código QR</string>
<string name="route">Rota</string>
@@ -1012,6 +1040,8 @@
<string name="torrent_download">Baixar</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_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="temporary_account">Terminar sessão no bloqueio do dispositivo</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="crashreport_found_send">Enviar</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>
@@ -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_global">Globalno</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_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)
@@ -112,6 +112,7 @@
<string name="post">Dela</string>
<string name="save">Spara</string>
<string name="create">Skapa</string>
<string name="rename">Byt namn</string>
<string name="cancel">Avbryt</string>
<string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string>
<string name="relay_address">Relä Adress</string>
@@ -443,6 +444,31 @@
<string name="follow_list_aroundme">Runt mig</string>
<string name="follow_list_global">Global</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_markdown"> ## Anslut genom Tor med Orbot
\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_high">Hög</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="login_with_qr_code">Logga in med QR-kod</string>
<string name="route">Rutt</string>
@@ -1011,6 +1039,8 @@
<string name="torrent_download">Ladda ner</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_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="temporary_account">Logga ut när enheten låses</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="crashreport_found_send">Skicka</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>
@@ -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="crashreport_found_send">发送它</string>
<string name="this_message_will_disappear_in_days">此消息将在 %1$d 天内消失</string>
<string name="select_signer">选择签名者</string>
</resources>
+2
View File
@@ -1039,6 +1039,8 @@
<string name="media_compression_quality_medium">Medium</string>
<string name="media_compression_quality_high">High</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>
+3 -3
View File
@@ -33,7 +33,7 @@ languageId = "17.0.6"
lazysodiumAndroid = "5.2.0"
lazysodiumJava = "5.2.0"
lifecycleRuntimeKtx = "2.9.4"
lightcompressor = "1.3.3"
lightcompressor = "1.5.0"
markdown = "f92ef49c9d"
media3 = "1.8.0"
mockk = "1.14.5"
@@ -63,7 +63,7 @@ core = "1.7.0"
mavenPublish = "0.34.0"
[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-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" }
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-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" }
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" }
zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }