Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-08-25 14:18:23 +00:00
committed by GitHub
90 changed files with 868 additions and 603 deletions
+13 -7
View File
@@ -4,33 +4,39 @@
Effective as of Jun 12, 2023
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
The Amethyst app for Android does not collect or process any personal information from its users.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts are only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Amethyst offers a few options to upload pictures and videos in order to post them online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
### Privacy with Relays services
Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
### Privacy with Relay services
Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online.
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
While the content of direct messages (DMs) is only visible to you, and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link on the DM text. Your uploaded pictures are available to anyone with that direct link.
While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link.
### Visibility & Permanence of Your Content on Nostr Relays
#### Information Visibility
Content that you share can be shared with other relays by any user of the network.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to Nostr users who do not share relays with you.
The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you.
#### Information Permanence
Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
## Child safety standards
Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user.
## Terms of Use
### For versions downloaded from Google's Play Store
+4 -4
View File
@@ -21,7 +21,7 @@ def getCurrentBranch() {
def generateVersionName(String baseVersion) {
def currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master") {
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
} else {
// Clean branch name for version (replace special characters)
@@ -44,9 +44,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 418
versionName = generateVersionName("0.94.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"fd42b23b9ef792059b1c1a89555443abbb11578f4b3c8430b452559eec7325f3\""
versionCode = 424
versionName = generateVersionName("1.00.5")
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -54,9 +54,9 @@ import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
@@ -82,7 +82,7 @@ class Amethyst : Application() {
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// creates okHttpClients based on the conditions of the connection and tor status
// manages all the other connections separately from relays.
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
@@ -92,12 +92,36 @@ class Amethyst : Application() {
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelaysForDms =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
val torProxySettingsAnchor = ProxySettingsAnchor()
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
okHttpClients.getHttpClient(torProxySettingsAnchor.useProxy(url))
if (torProxySettingsAnchor.isDM(url)) {
okHttpClientForRelaysForDms.getHttpClient(torProxySettingsAnchor.useProxy(url))
} else {
okHttpClientForRelays.getHttpClient(torProxySettingsAnchor.useProxy(url))
}
}
// Caches all events in Memory
@@ -214,6 +214,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -1158,11 +1159,25 @@ class Account(
return event
}
suspend fun createAndSendDraft(
suspend fun createAndSendDraftIgnoreErrors(
draftTag: String,
template: EventTemplate<out Event>,
broadcast: Set<Event> = emptySet(),
) {
try {
createAndSendDraftInner(draftTag, template, broadcast)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
suspend fun createAndSendDraftInner(
draftTag: String,
template: EventTemplate<out Event>,
broadcast: Set<Event> = emptySet(),
) {
if (!isWriteable()) return
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
@@ -1180,7 +1195,17 @@ class Account(
}
}
suspend fun deleteDraft(draftTag: String) {
suspend fun deleteDraftIgnoreErrors(draftTag: String) {
try {
deleteDraftInner(draftTag)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
suspend fun deleteDraftInner(draftTag: String) {
if (!isWriteable()) return
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
@@ -1307,7 +1332,7 @@ class Account(
}
if (draftTag != null) {
createAndSendDraft(draftTag, template)
createAndSendDraftIgnoreErrors(draftTag, template)
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
@@ -1352,7 +1377,7 @@ class Account(
val broadcastNotes = mapEntitiesToNotes(quotes).toSet()
if (draftTag != null) {
createAndSendDraft(draftTag, template)
createAndSendDraftIgnoreErrors(draftTag, template)
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.collections.ifEmpty
class FollowsPerOutboxRelay(
kind3Follows: FollowListState,
@@ -68,7 +70,11 @@ class FollowsPerOutboxRelay(
mapOfSet {
relayListNotes.forEach { noteState ->
noteState.note.author?.pubkeyHex?.let { authorHex ->
(noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay ->
val outboxRelayList =
getNIP65RelayList(authorHex)?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null }
?: Constants.eventFinderRelays
outboxRelayList.forEach { relay ->
add(relay, authorHex)
}
}
@@ -85,7 +91,11 @@ class FollowsPerOutboxRelay(
emit(
mapOfSet {
kind3Follows.flow.value.authors.map { authorHex ->
getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay ->
val outboxRelayList =
getNIP65RelayList(authorHex)?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null }
?: Constants.eventFinderRelays
outboxRelayList.forEach { relay ->
add(relay, authorHex)
}
}
@@ -1,109 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip65RelayList
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class OutboxRelaySetState(
usersToLoad: MutableStateFlow<Set<HexKey>>,
val cache: LocalCache,
scope: CoroutineScope,
) {
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.Companion.createAddress(pubkey)
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Set<NormalizedRelayUrl>> =
combine(flows) { relayListNotes: Array<NoteState> ->
relayListNotes.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
}
}.map {
it.flatten().toSet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
usersToLoad
.transformLatest { followList ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList)
val relayListFlows = combineAllFlows(flows)
emitAll(relayListFlows)
}.onStart {
emit(
usersToLoad.value
.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()
}.flatten()
.toSet(),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<String>> =
flow
.map { relayList ->
relayList.map { it.url }.toSet()
}.onStart {
emit(
usersToLoad.value
.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet()
}.flatten()
.toSet(),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
}
@@ -64,40 +64,42 @@ class AppSpecificState(
}
init {
settings.backupAppSpecificData?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
try {
val decrypted = signer.decrypt(event.content, event.pubKey)
val syncedSettings = JsonMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
settings.syncedSettings.updateFrom(syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value", e)
if (settings.isWriteable()) {
settings.backupAppSpecificData?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
try {
val decrypted = signer.decrypt(event.content, event.pubKey)
val syncedSettings = JsonMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
settings.syncedSettings.updateFrom(syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value", e)
}
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "AppSpecificData Collector Start")
getAppSpecificDataFlow().collect {
try {
Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}")
(it.note.event as? AppSpecificDataEvent)?.let {
val decrypted = signer.decrypt(it.content, it.pubKey)
try {
val syncedSettings = JsonMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
settings.updateAppSpecificData(it, syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e)
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "AppSpecificData Collector Start")
getAppSpecificDataFlow().collect {
try {
Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}")
(it.note.event as? AppSpecificDataEvent)?.let {
val decrypted = signer.decrypt(it.content, it.pubKey)
try {
val syncedSettings = JsonMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
settings.updateAppSpecificData(it, syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e)
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decrypting latestAppSpecificData from Preferences", e)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decrypting latestAppSpecificData from Preferences", e)
}
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
class CommunityRelayLoader {
@@ -81,8 +82,12 @@ class CommunityRelayLoader {
?.stateFlow
}
return combine(noteMetadataFlows) { communityNotes ->
transformation(communitiesPerRelay(communityNotes, cache))
return if (noteMetadataFlows.isEmpty()) {
MutableStateFlow(transformation(emptyMap()))
} else {
combine(noteMetadataFlows) { communityNotes ->
transformation(communitiesPerRelay(communityNotes, cache))
}
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
class OutboxRelayLoader {
@@ -49,8 +51,9 @@ class OutboxRelayLoader {
if (authorHex != null) {
val relays =
(outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.ifEmpty { null }
?: cache.relayHints.hintsForKey(authorHex)
(outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
?: cache.relayHints.hintsForKey(authorHex).ifEmpty { null }
?: Constants.eventFinderRelays
relays.forEach {
add(it, authorHex)
@@ -86,8 +89,12 @@ class OutboxRelayLoader {
note.flow().metadata.stateFlow
}
return combine(noteMetadataFlows) { outboxRelays ->
transformation(authorsPerRelay(outboxRelays, cache))
return if (noteMetadataFlows.isEmpty()) {
MutableStateFlow(transformation(emptyMap()))
} else {
combine(noteMetadataFlows) { outboxRelays ->
transformation(authorsPerRelay(outboxRelays, cache))
}
}
}
}
@@ -86,6 +86,7 @@ class AllFollowsByOutboxTopNavFilter(
} else {
MutableStateFlow(emptyMap())
}
val communitiesPerRelay =
if (communities != null) {
CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it }
@@ -116,6 +117,7 @@ class AllFollowsByOutboxTopNavFilter(
} else {
emptyMap()
}
val communitiesPerRelay =
if (communities != null) {
CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it }
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
@Immutable
class SingleCommunityTopNavPerRelayFilter(
val community: String,
val authors: Set<String>?,
) : IFeedTopNavPerRelayFilter
) : IFeedTopNavPerRelayFilter {
val communityAddress = Address.parse(community)
}
@@ -73,13 +73,12 @@ class OkHttpClientFactory(
userAgent: String,
): OkHttpClient {
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
val duration = Duration.ofSeconds(seconds.toLong())
return rootClient
.newBuilder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
@@ -38,5 +38,6 @@ class ProxySettingsAnchor {
),
)
var isDM: (NormalizedRelayUrl) -> Boolean = { it in flow.value.value.dmRelayList }
var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) }
}
@@ -67,10 +67,11 @@ fun GetVideoController(
scope.launch {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
controllerId,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
@@ -174,10 +175,11 @@ fun GetVideoController(
scope.launch(Dispatchers.Main) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
controllerId,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.composable
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@@ -49,7 +50,6 @@ fun RenderVideoPlayer(
thumbData: VideoThumb?,
showControls: Boolean = true,
contentScale: ContentScale,
waveform: WaveformData? = null,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -64,6 +64,13 @@ fun RenderVideoPlayer(
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
@@ -101,7 +108,7 @@ fun RenderVideoPlayer(
},
)
waveform?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) {
RenderControlButtons(
@@ -55,14 +55,16 @@ fun VideoViewInner(
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(
videoUri,
title,
artworkUri,
authorName,
nostrUriCallback,
mimeType,
aspectRatio,
videoUri = videoUri,
title = title,
artworkUri = artworkUri,
authorName = authorName,
callbackUri = nostrUriCallback,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = accountViewModel.proxyPortFor(videoUri),
keepPlaying = true,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -76,7 +78,6 @@ fun VideoViewInner(
thumbData = thumb,
showControls = showControls,
contentScale = contentScale,
waveform = waveform,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
val mediaItemCache = MediaItemCache()
@@ -37,6 +38,8 @@ fun GetMediaItem(
mimeType: String? = null,
aspectRatio: Float? = null,
proxyPort: Int? = null,
keepPlaying: Boolean = false,
waveformData: WaveformData? = null,
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
@@ -50,6 +53,8 @@ fun GetMediaItem(
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = proxyPort,
keepPlaying = keepPlaying,
waveformData = waveformData,
)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@Immutable
data class MediaItemData(
@@ -33,6 +34,8 @@ data class MediaItemData(
val mimeType: String? = null,
val aspectRatio: Float? = null,
val proxyPort: Int? = null,
val keepPlaying: Boolean = true,
val waveformData: WaveformData? = null,
)
@Immutable
@@ -82,6 +82,12 @@ fun Waveform(
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
}
}
LaunchedEffect(Unit) {
delay(500)
val position = mediaControllerState.controller?.let { it.currentPosition / it.duration.toFloat() } ?: 0f
waveformProgress.floatValue = position
}
}
private fun pollCurrentDuration(controller: MediaController) =
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.R.attr.data
import android.graphics.Rect
import android.os.Bundle
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.quartz.utils.ensure
@@ -58,6 +60,8 @@ class IntentExtras {
mimeType = intent.getString("mimeType"),
aspectRatio = if (ratio > 0) ratio else null,
proxyPort = if (port > 0) port else null,
keepPlaying = intent.getBoolean("keepPlaying", true),
waveformData = intent.getFloatArray("wavefrontData")?.toList()?.let { WaveformData(it) },
)
}
@@ -74,6 +78,8 @@ class IntentExtras {
data.mimeType?.let { putString("mimeType", it) }
data.aspectRatio?.let { putFloat("aspectRatio", it) }
data.proxyPort?.let { putInt("proxyPort", it) }
data.keepPlaying.let { putBoolean("keepPlaying", it) }
data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) }
bounds?.let { putInt("boundLeft", it.left) }
bounds?.let { putInt("boundRight", it.right) }
@@ -24,6 +24,7 @@ import android.content.Context
import android.content.Intent
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -42,8 +43,11 @@ import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@Composable
fun PipVideo() {
@@ -69,7 +73,7 @@ fun PipVideo() {
videoData?.let {
GetMediaItem(it) { mediaItem ->
GetVideoController(mediaItem, false) { controller ->
PipVideo(controller)
PipVideo(controller, it.waveformData)
}
}
}
@@ -77,7 +81,10 @@ fun PipVideo() {
@OptIn(UnstableApi::class)
@Composable
fun PipVideo(controller: MediaControllerState) {
fun PipVideo(
controller: MediaControllerState,
waveformData: WaveformData?,
) {
DisposableEffect(controller) {
BackgroundMedia.switchKeepPlaying(controller)
onDispose {
@@ -117,5 +124,9 @@ fun PipVideo(controller: MediaControllerState) {
}
},
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
waveformData?.let { Waveform(it, controller, Modifier) }
}
}
}
@@ -56,7 +56,7 @@ class SessionListener(
class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val reset: (MediaSession) -> Unit,
val reset: (MediaSession, Boolean) -> Unit,
) {
val globalCallback = MediaSessionCallback(this)
var lastCleanup = TimeUtils.now()
@@ -65,7 +65,7 @@ class MediaSessionPool(
private val playingMap = mutableMapOf<String, SessionListener>()
private val cache =
object : LruCache<String, SessionListener>(SimultaneousPlaybackCalculator.max()) { // up to 10 videos in the screen at the same time
object : LruCache<String, SessionListener>(10) { // up to 10 videos in the screen at the same time
override fun entryRemoved(
evicted: Boolean,
key: String?,
@@ -87,6 +87,7 @@ class MediaSessionPool(
@OptIn(UnstableApi::class)
fun newSession(
id: String,
keepPlaying: Boolean,
context: Context,
): MediaSession {
val mediaSession =
@@ -107,7 +108,7 @@ class MediaSessionPool(
mediaSession.player.addListener(listener)
reset(mediaSession)
reset(mediaSession, keepPlaying)
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
@@ -167,6 +168,7 @@ class MediaSessionPool(
fun getSession(
id: String,
keepPlaying: Boolean,
context: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
@@ -174,7 +176,7 @@ class MediaSessionPool(
return existingSession.session
}
return newSession(id, context)
return newSession(id, keepPlaying, context)
}
fun playingContent() = playingMap.values
@@ -44,9 +44,9 @@ class PlaybackService : MediaSessionService() {
MediaSessionPool(
ExoPlayerPool(ExoPlayerBuilder(okHttp)),
okHttpClient = okHttp,
reset = { session ->
reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply {
repeatMode = Player.REPEAT_MODE_ONE
repeatMode = if (keepPlaying) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
}
@@ -145,7 +145,8 @@ class PlaybackService : MediaSessionService() {
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
val id = controllerInfo.connectionHints.getString("id") ?: return null
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true)
val manager = lazyPool(proxyPort)
return manager.getSession(id, applicationContext)
return manager.getSession(id, keepPlaying, applicationContext)
}
}
@@ -60,6 +60,7 @@ object PlaybackServiceClient {
mediaControllerState: MediaControllerState,
videoUri: String,
proxyPort: Int? = 0,
keepPlaying: Boolean = true,
context: Context,
onReady: (MediaControllerState) -> Unit,
) {
@@ -73,6 +74,7 @@ object PlaybackServiceClient {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", mediaControllerState.id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
@@ -429,56 +429,47 @@ fun observeUserIsFollowing(
return flow.collectAsStateWithLifecycle(user1.isFollowing(user2))
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingHashtag(
user: User,
hashtag: String,
accountViewModel: AccountViewModel,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowingHashtag(hashtag)
remember(accountViewModel) {
accountViewModel.account.hashtagList.flow
.mapLatest { hashtags ->
hashtag in hashtags
}.onStart {
emit(hashtag in accountViewModel.account.hashtagList.flow.value)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.isFollowingHashtag(hashtag))
return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.hashtagList.flow.value)
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingGeohash(
user: User,
geohash: String,
accountViewModel: AccountViewModel,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowingGeohash(geohash)
remember(accountViewModel) {
accountViewModel.account.geohashList.flow
.mapLatest { geohashes ->
geohash in geohashes
}.onStart {
emit(geohash in accountViewModel.account.geohashList.flow.value)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.isFollowingGeohash(geohash))
return flow.collectAsStateWithLifecycle(geohash in accountViewModel.account.geohashList.flow.value)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -52,7 +52,7 @@ fun filterFindUserMetadataForKey(
mapOfSet {
authors.forEach { key ->
val relays =
key.authorRelayList()?.writeRelaysNorm()?.ifEmpty { null }
key.authorRelayList()?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(key.pubkeyHex).ifEmpty { null }
?: (key.relaysBeingUsed.keys + defaultRelays).toList()
@@ -50,7 +50,7 @@ fun filterUserMetadataForKey(
val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it)
val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent)
authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null }
authorHomeRelayEvent?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null }
?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay)
}.flatten()
@@ -58,12 +58,12 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -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
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -50,22 +50,30 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TextSpinner(
label: String?,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
TextSpinner(
BaseTextSpinner(
placeholder,
options,
onSelect,
@@ -89,12 +97,38 @@ fun TextSpinner(
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit,
) {
BaseTextSpinner(
placeholder = placeholder,
options = options,
onSelect = onSelect,
modifier = modifier,
mainElement = mainElement,
)
}
@Composable
private fun BaseTextSpinner(
placeholder: String,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember(placeholder) { mutableStateOf(placeholder) }
val accessibilityDescription =
if (currentText == placeholder) {
"Dropdown menu, $placeholder, not selected"
} else {
"Dropdown menu, $currentText selected"
}
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -105,13 +139,23 @@ fun TextSpinner(
)
Box(
modifier =
Modifier.matchParentSize().clickable(
interactionSource = interactionSource,
indication = null,
) {
optionsShowing = true
focusRequester.requestFocus()
},
Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null,
) {
optionsShowing = true
focusRequester.requestFocus()
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = openDropdownLabel) {
optionsShowing = true
focusRequester.requestFocus()
return@onClick true
}
},
)
}
@@ -187,8 +231,17 @@ fun <T> SpinnerSelectionDialog(
}
}
itemsIndexed(options) { index, item ->
val optionsOfLabel = stringRes(R.string.option_of, index + 1, options.size)
Row(
modifier = Modifier.fillMaxWidth().clickable { onSelect(index) }.padding(16.dp, 16.dp),
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelect(index) }
.padding(16.dp, 16.dp)
.semantics {
role = Role.Button
contentDescription = optionsOfLabel
},
) {
Column { onRenderItem(item) }
}
@@ -44,6 +44,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -56,6 +61,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName
@@ -66,7 +72,6 @@ import com.vitorpamplona.amethyst.ui.screen.Name
import com.vitorpamplona.amethyst.ui.screen.PeopleListName
import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -113,6 +118,13 @@ fun FeedFilterSpinner(
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
}
val accessibilityDescription =
if (selected != null) {
stringRes(R.string.feed_filter_selected, currentText)
} else {
stringRes(R.string.feed_filter_select_an_option, selectAnOption)
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -195,6 +207,13 @@ fun FeedFilterSpinner(
indication = null,
) {
optionsShowing = true
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = "Open feed filter menu") {
optionsShowing = true
return@onClick true
}
},
)
}
@@ -202,6 +221,7 @@ fun FeedFilterSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
@@ -260,12 +280,18 @@ fun RenderOption(
val noteEvent = noteState.note.event
val name =
if (noteEvent is PeopleListEvent) {
noteEvent.nameOrTitle() ?: option.note.dTag()
} else if (noteEvent is FollowListEvent) {
noteEvent.title() ?: option.note.dTag()
} else {
option.note.dTag()
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.nameOrTitle() ?: option.note.dTag()
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
}
Text(text = name, color = MaterialTheme.colorScheme.onSurface)
@@ -278,7 +304,7 @@ fun RenderOption(
) {
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it?.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
}
}
}
@@ -240,6 +240,7 @@ fun NoteCompose(
WatchNoteEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav,
modifier,
) {
CheckHiddenFeedWatchBlockAndReport(
@@ -312,7 +313,7 @@ fun AcceptableNote(
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup ->
CheckNewAndRenderNote(
baseNote = baseNote,
modifier = modifier,
@@ -356,9 +357,9 @@ fun AcceptableNote(
nav = nav,
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel)
is BadgeDefinitionEvent -> BadgeDisplay(baseNote, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
CheckNewAndRenderNote(
baseNote = baseNote,
modifier = modifier,
@@ -798,7 +799,7 @@ private fun RenderNoteRow(
is VideoNormalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is VideoShortEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
is BaseVoiceEvent -> RenderVoiceTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is BaseVoiceEvent -> RenderVoiceTrack(baseNote, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
is CommunityPostApprovalEvent -> {
RenderPostApproval(
@@ -82,7 +82,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.painterRes
@@ -142,6 +141,7 @@ val externalLinkForNote = { note: Note ->
fun LongPressToQuickAction(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
content: @Composable (() -> Unit) -> Unit,
) {
val popupExpanded = remember { mutableStateOf(false) }
@@ -153,7 +153,7 @@ fun LongPressToQuickAction(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = EmptyNav,
nav = nav,
)
}
}
@@ -81,12 +81,12 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
@@ -97,7 +97,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
fun UpdateZapAmountDialog(
@@ -536,7 +535,7 @@ fun authenticate(
fun keyguardPrompt() {
val intent =
keyguardManager.createConfirmDeviceCredentialIntent(
stringRes(context, R.string.app_name_release),
stringRes(context, R.string.app_name),
title,
)
@@ -556,7 +555,7 @@ fun authenticate(
val promptInfo =
BiometricPrompt.PromptInfo
.Builder()
.setTitle(stringRes(context, R.string.app_name_release))
.setTitle(stringRes(context, R.string.app_name))
.setSubtitle(title)
.setAllowedAuthenticators(authenticators)
.build()
@@ -29,6 +29,8 @@ import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalFoundationApi::class)
@@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun WatchNoteEvent(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
shortPreview: Boolean = false,
onNoteEventFound: @Composable () -> Unit,
@@ -47,6 +50,7 @@ fun WatchNoteEvent(
LongPressToQuickAction(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
) { showPopup ->
BlankNote(
remember {
@@ -65,11 +65,11 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -61,11 +61,11 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@@ -317,12 +317,14 @@ open class CommentPostViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.deleteDraft(version)
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -331,7 +333,7 @@ open class CommentPostViewModel :
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -45,7 +45,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -86,7 +85,6 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
@@ -144,16 +142,16 @@ fun GenericCommentPostScreen(
onCancel = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
nav.popBack()
postViewModel.cancel()
}
nav.popBack()
},
onPost = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.runIOCatching {
postViewModel.sendPostSync()
nav.popBack()
}
@@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -87,8 +86,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
@Composable
@@ -362,10 +359,10 @@ fun WatchAddressableNoteFollows(
accountViewModel: AccountViewModel,
onFollowChanges: @Composable (Boolean) -> Unit,
) {
val state by accountViewModel.account.kind3FollowList.flow
val state by accountViewModel.account.communityList.flowSet
.collectAsStateWithLifecycle()
onFollowChanges(state.communities.contains(note.idHex))
onFollowChanges(state.contains(note.idHex))
}
@Composable
@@ -376,16 +373,9 @@ fun JoinCommunityButton(
) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note)
}
},
onClick = { accountViewModel.follow(note) },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
contentPadding = ButtonPadding,
) {
Text(text = stringRes(R.string.join), color = Color.White)
@@ -402,7 +392,7 @@ fun LeaveCommunityButton(
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } },
onClick = { accountViewModel.unfollow(note) },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
@@ -20,22 +20,56 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.PlayCircleOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia.isPlaying
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.Size75Modifier
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@@ -43,23 +77,28 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@Composable
fun RenderVoiceTrack(
note: Note,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? BaseVoiceEvent ?: return
VoiceHeader(noteEvent, note, contentScale, accountViewModel, nav)
VoiceHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
fun VoiceHeader(
noteEvent: BaseVoiceEvent,
note: Note,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val media =
remember(noteEvent) {
noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url
}
if (media == null) return
val waveform =
remember(noteEvent) {
noteEvent
@@ -68,32 +107,40 @@ fun VoiceHeader(
?.waveform
?.let { WaveformData(it) }
}
val media =
remember(noteEvent) {
noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url
}
if (media == null) return
val callbackUri = remember(note) { note.toNostrUri() }
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
VideoView(
GetMediaItem(
videoUri = media,
mimeType = null,
waveform = waveform,
title = noteEvent.subject(),
artworkUri = null,
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
contentScale = contentScale,
accountViewModel = accountViewModel,
nostrUriCallback = note.toNostrUri(),
)
callbackUri = callbackUri,
mimeType = null,
aspectRatio = null,
proxyPort = accountViewModel.proxyPortFor(media),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
)
}
}
}
val callbackUri = remember(note) { note.toNostrUri() }
if (noteEvent.hasHashtags()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav)
@@ -101,3 +148,119 @@ fun VoiceHeader(
}
}
}
@Composable
@OptIn(UnstableApi::class)
fun RenderVoicePlayer(
mediaItem: LoadedMediaItem,
controllerState: MediaControllerState,
waveform: WaveformData? = null,
borderModifier: Modifier,
accountViewModel: AccountViewModel,
) {
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) {
AndroidView(
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = true
hideController()
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
},
)
}
},
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
PlayPauseButton(controllerState)
waveform?.let { Waveform(it, controllerState, Modifier) }
}
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
)
}
}
@Composable
fun PlayPauseButton(controllerState: MediaControllerState) {
val controller = controllerState.controller
var isPlaying by remember(controller) {
mutableStateOf(controllerState.isPlaying())
}
if (controller != null) {
val view = LocalView.current
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = controller, key2 = view) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (view.keepScreenOn != playing) {
view.keepScreenOn = playing
}
isPlaying = playing
}
fun destroy() {
if (view.keepScreenOn) {
view.keepScreenOn = false
}
isPlaying = false
}
}
controller.addListener(listener)
onDispose {
controller.removeListener(listener)
listener.destroy()
}
}
// Play/Pause Button
IconButton(
onClick = {
if (controller.isPlaying) {
controller.pause()
} else {
controller.play()
}
},
modifier = Size75Modifier,
) {
Icon(
imageVector = if (isPlaying) Icons.Default.PauseCircleOutline else Icons.Default.PlayCircleOutline,
contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play),
modifier = Size50Modifier,
tint = Color.White,
)
}
}
}
@@ -66,7 +66,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@Stable
class FollowListState(
@@ -160,12 +159,12 @@ class FollowListState(
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<List<FeedDefinition>> =
account.kind3FollowList.flow.transformLatest {
combineTransform(account.kind3FollowList.flow, account.hashtagList.flow, account.geohashList.flow, account.communityList.flow) { kind3List, hashtagList, geotagList, communityList ->
checkNotInMainThread()
val communities =
it.communities.mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
communityList.mapNotNull {
LocalCache.getOrCreateAddressableNote(it.address).let { communityNote ->
TagFeedDefinition(
"Community/${communityNote.idHex}",
CommunityName(communityNote),
@@ -178,7 +177,7 @@ class FollowListState(
}
val hashtags =
it.hashtags.map {
hashtagList.map {
TagFeedDefinition(
"Hashtag/$it",
HashtagName(it),
@@ -190,7 +189,7 @@ class FollowListState(
}
val geotags =
it.geotags.map {
geotagList.map {
TagFeedDefinition(
"Geohash/$it",
GeoHashName(it),
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivitie
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DiscoverNIP89FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.DiscoverMarketplaceFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
@@ -68,6 +69,8 @@ class AccountFeedContentStates(
val feedListOptions = FollowListState(accountViewModel.account, accountViewModel.viewModelScope)
val drafts = FeedContentState(DraftEventsFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
suspend fun init() {
notificationSummary.initializeSuspend()
feedListOptions.initializeSuspend()
@@ -97,6 +100,8 @@ class AccountFeedContentStates(
notificationSummary.invalidateInsertData(newNotes)
feedListOptions.updateFeedWith(newNotes)
drafts.updateFeedWith(newNotes)
}
fun destroy() {
@@ -222,7 +222,7 @@ class AccountViewModel(
}
}
if (flows != null) {
if (!flows.isNullOrEmpty()) {
combine(flows) {
it.any { it }
}
@@ -667,9 +667,7 @@ class AccountViewModel(
note: Note,
type: ReportType,
content: String = "",
) {
runIOCatching { account.report(note, type, content) }
}
) = runIOCatching { account.report(note, type, content) }
fun report(
user: User,
@@ -681,17 +679,11 @@ class AccountViewModel(
}
}
fun boost(note: Note) {
runIOCatching { account.boost(note) }
}
fun boost(note: Note) = runIOCatching { account.boost(note) }
fun removeEmojiPack(emojiPack: Note) {
runIOCatching { account.removeEmojiPack(emojiPack) }
}
fun removeEmojiPack(emojiPack: Note) = runIOCatching { account.removeEmojiPack(emojiPack) }
fun addEmojiPack(emojiPack: Note) {
runIOCatching { account.addEmojiPack(emojiPack) }
}
fun addEmojiPack(emojiPack: Note) = runIOCatching { account.addEmojiPack(emojiPack) }
fun addMediaToGallery(
hex: String,
@@ -701,13 +693,9 @@ class AccountViewModel(
dim: DimensionTag?,
hash: String?,
mimeType: String?,
) {
runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) }
}
) = runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) }
fun removeFromMediaGallery(note: Note) {
runIOCatching { account.removeFromGallery(note) }
}
fun removeFromMediaGallery(note: Note) = runIOCatching { account.removeFromGallery(note) }
fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
@@ -736,13 +724,9 @@ class AccountViewModel(
}
}
fun delete(notes: List<Note>) {
runIOCatching { account.delete(notes) }
}
fun delete(notes: List<Note>) = runIOCatching { account.delete(notes) }
fun delete(note: Note) {
runIOCatching { account.delete(note) }
}
fun delete(note: Note) = runIOCatching { account.delete(note) }
fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note)
@@ -790,61 +774,33 @@ class AccountViewModel(
}
}
fun follow(community: AddressableNote) {
runIOCatching { account.follow(community) }
}
fun follow(community: AddressableNote) = runIOCatching { account.follow(community) }
fun follow(channel: PublicChatChannel) {
runIOCatching { account.follow(channel) }
}
fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) }
fun follow(channel: EphemeralChatChannel) {
runIOCatching { account.follow(channel) }
}
fun follow(channel: EphemeralChatChannel) = runIOCatching { account.follow(channel) }
fun unfollow(community: AddressableNote) {
runIOCatching { account.unfollow(community) }
}
fun unfollow(community: AddressableNote) = runIOCatching { account.unfollow(community) }
fun unfollow(channel: PublicChatChannel) {
runIOCatching { account.unfollow(channel) }
}
fun unfollow(channel: PublicChatChannel) = runIOCatching { account.unfollow(channel) }
fun unfollow(channel: EphemeralChatChannel) {
runIOCatching { account.unfollow(channel) }
}
fun unfollow(channel: EphemeralChatChannel) = runIOCatching { account.unfollow(channel) }
fun follow(user: User) {
runIOCatching { account.follow(user) }
}
fun follow(user: User) = runIOCatching { account.follow(user) }
fun unfollow(user: User) {
runIOCatching { account.unfollow(user) }
}
fun unfollow(user: User) = runIOCatching { account.unfollow(user) }
fun followGeohash(tag: String) {
runIOCatching { account.followGeohash(tag) }
}
fun followGeohash(tag: String) = runIOCatching { account.followGeohash(tag) }
fun unfollowGeohash(tag: String) {
runIOCatching { account.unfollowGeohash(tag) }
}
fun unfollowGeohash(tag: String) = runIOCatching { account.unfollowGeohash(tag) }
fun followHashtag(tag: String) {
runIOCatching { account.followHashtag(tag) }
}
fun followHashtag(tag: String) = runIOCatching { account.followHashtag(tag) }
fun unfollowHashtag(tag: String) {
runIOCatching { account.unfollowHashtag(tag) }
}
fun unfollowHashtag(tag: String) = runIOCatching { account.unfollowHashtag(tag) }
fun showWord(word: String) {
runIOCatching { account.showWord(word) }
}
fun showWord(word: String) = runIOCatching { account.showWord(word) }
fun hideWord(word: String) {
runIOCatching { account.hideWord(word) }
}
fun hideWord(word: String) = runIOCatching { account.hideWord(word) }
fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex
@@ -927,17 +883,14 @@ class AccountViewModel(
fun updateStatus(
address: Address,
newStatus: String,
) {
runIOCatching {
account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus)
}
) = runIOCatching {
account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus)
}
fun deleteStatus(address: Address) {
fun deleteStatus(address: Address) =
runIOCatching {
account.deleteStatus(LocalCache.getOrCreateAddressableNote(address))
}
}
fun urlPreview(
url: String,
@@ -1506,10 +1459,6 @@ class AccountViewModel(
fun dataSources() = app.sources
suspend fun deleteDraft(draftTag: String) {
account.deleteDraft(draftTag)
}
suspend fun createTempDraftNote(noteEvent: DraftWrapEvent): Note? = draftNoteCache.update(noteEvent)
fun createTempDraftNote(
@@ -1582,7 +1531,7 @@ class AccountViewModel(
root: InteractiveStoryBaseEvent,
readingScene: InteractiveStoryBaseEvent,
) {
viewModelScope.launch(Dispatchers.IO) {
runIOCatching {
val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl()
val readingState = getInteractiveStoryReadingState(root.addressTag())
@@ -97,7 +97,7 @@ fun ChatroomMessageCompose(
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) {
WatchBlockAndReport(
note = baseNote,
showHiddenWarning = false,
@@ -48,7 +48,7 @@ fun filterNip04DMs(
val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent)
val outbox =
authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null }
authorHomeRelayEvent?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null }
?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay)
@@ -112,7 +112,9 @@ class ChatNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
sendDraftSync()
accountViewModel.runIOCatching {
sendDraftSync()
}
}
}
}
@@ -349,12 +351,14 @@ class ChatNewMessageViewModel :
val version = draftTag.current
innerSendPost(null)
cancel()
accountViewModel.deleteDraft(version)
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
account.deleteDraft(draftTag.current)
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
innerSendPost(draftTag.current)
}
@@ -469,7 +473,7 @@ class ChatNewMessageViewModel :
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraft(draftTag, template)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
@@ -486,7 +490,7 @@ class ChatNewMessageViewModel :
)
if (draftTag != null) {
accountViewModel.account.createAndSendDraft(draftTag, template)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip04PrivateMessage(template)
}
@@ -175,10 +175,9 @@ fun NewGroupDMScreen(
// function when the postViewModel is released
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
delay(100)
nav.popBack()
postViewModel.cancel()
}
nav.popBack()
},
onPost = {
// uses the accountViewModel scope to avoid cancelling this
@@ -188,7 +187,6 @@ fun NewGroupDMScreen(
postViewModel.room?.let {
nav.nav(routeToMessage(it, null, null, null, accountViewModel))
}
postViewModel.cancel()
}
nav.popBack()
},
@@ -106,7 +106,9 @@ open class ChannelNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
sendDraftSync()
accountViewModel.runIOCatching {
sendDraftSync()
}
}
}
}
@@ -260,7 +262,7 @@ open class ChannelNewMessageViewModel :
}
fun sendPost(onDone: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.runIOCatching {
sendPostSync()
onDone()
}
@@ -274,12 +276,14 @@ open class ChannelNewMessageViewModel :
cancel()
accountViewModel.account.signAndSendPrivately(template, channelRelays)
accountViewModel.deleteDraft(version)
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
account.deleteDraft(draftTag.current)
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -288,7 +292,7 @@ open class ChannelNewMessageViewModel :
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -58,14 +58,14 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -62,7 +62,7 @@ fun ChannelCardCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) {
if (forceEventKind == null || baseNote.event?.kind == forceEventKind) {
CheckHiddenFeedWatchBlockAndReport(
note = baseNote,
@@ -94,7 +94,7 @@ fun NormalChannelCard(
accountViewModel: AccountViewModel,
nav: INav,
) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
routeForLastRead,
@@ -42,7 +42,7 @@ fun filterCommunitiesAllCommunities(
filter =
Filter(
kinds = CommunityPostApprovalEvent.KIND_LIST,
ids = communityList,
tags = mapOf("a" to communityList),
limit = 300,
since = since,
),
@@ -25,11 +25,12 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
fun filterClassifiedsByCommunity(
relay: NormalizedRelayUrl,
community: String,
community: Address,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
@@ -40,9 +41,9 @@ fun filterClassifiedsByCommunity(
relay = relay,
filter =
Filter(
authors = authors,
authors = listOf(community.pubKeyHex),
kinds = listOf(CommunityDefinitionEvent.KIND),
ids = listOf(community),
tags = mapOf("d" to listOf(community.dTag)),
limit = 300,
since = since,
),
@@ -59,11 +60,13 @@ fun filterCommunitiesByCommunity(
return communitySet.set
.mapNotNull {
filterClassifiedsByCommunity(
relay = it.key,
community = it.value.community,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
it.value.communityAddress?.let { address ->
filterClassifiedsByCommunity(
relay = it.key,
community = address,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterCommunitiesGlobal(
@@ -42,7 +41,7 @@ fun filterCommunitiesGlobal(
relay = it.key,
filter =
Filter(
kinds = listOf(CommunityDefinitionEvent.KIND),
kinds = CommunityPostApprovalEvent.KIND_LIST,
limit = 100,
since = since,
),
@@ -51,7 +50,7 @@ fun filterCommunitiesGlobal(
relay = it.key,
filter =
Filter(
kinds = listOf(CommunityPostApprovalEvent.KIND),
kinds = CommunityPostApprovalEvent.KIND_LIST,
limit = 100,
since = since ?: TimeUtils.oneWeekAgo(),
),
@@ -142,22 +142,19 @@ fun NewProductScreen(
titleRes = R.string.new_product,
isActive = postViewModel::canPost,
onCancel = {
try {
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.sendDraftSync()
nav.popBack()
postViewModel.cancel()
}
} catch (e: SignerExceptions.ReadOnlyException) {
// do nothing.
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
},
onPost = {
try {
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.sendPostSync()
nav.popBack()
postViewModel.cancel()
}
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
@@ -104,20 +104,22 @@ open class NewProductViewModel :
IZapRaiser {
val draftTag = DraftTagState()
var accountViewModel: AccountViewModel? = null
var account: Account? = null
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
sendDraftSync()
accountViewModel?.runIOCatching {
sendDraftSync()
}
}
}
}
}
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
val iMetaDescription = IMetaAttachments()
@@ -288,26 +290,26 @@ open class NewProductViewModel :
}
suspend fun sendPostSync() {
val accountViewModel = accountViewModel ?: return
val template = createTemplate() ?: return
accountViewModel?.account?.signAndSendPrivatelyOrBroadcast(
template,
relayList = { relayList },
)
accountViewModel?.deleteDraft(draftTag.current)
val version = draftTag.current
cancel()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template, relayList = { relayList })
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
val accountViewModel = accountViewModel ?: return
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template)
}
}
@@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -211,7 +211,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
}
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
placeholder = conditionTypes.first { it.first == postViewModel.condition }.second,
options = conditionOptions,
onSelect = {
postViewModel.updateCondition(conditionTypes[it].first)
@@ -38,32 +38,27 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -74,42 +69,16 @@ fun DraftListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val draftFeedViewModel: DraftEventsFeedViewModel =
viewModel(
key = "NostrDraftEventsFeedViewModel",
factory = DraftEventsFeedViewModel.Factory(accountViewModel.account),
)
RenderDraftListScreen(draftFeedViewModel, accountViewModel, nav)
RenderDraftListScreen(accountViewModel.feedStates.drafts, accountViewModel, nav)
}
@Composable
private fun RenderDraftListScreen(
feedViewModel: DraftEventsFeedViewModel,
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(feedViewModel) {
feedViewModel.invalidateData()
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("DraftList Start")
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("DraftList Stop")
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
WatchLifecycleAndUpdateModel(feedState)
DisappearingScaffold(
isInvertedLayout = false,
@@ -119,15 +88,15 @@ private fun RenderDraftListScreen(
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it).fillMaxHeight()) {
RefresheableBox(feedViewModel) {
SaveableFeedState(feedViewModel.feedState, DRAFTS) { listState ->
RenderFeedState(
viewModel = feedViewModel,
RefresheableBox(feedState) {
SaveableFeedState(feedState, DRAFTS) { listState ->
RenderFeedContentState(
feedContentState = feedState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = null,
onLoaded = { DraftFeedLoaded(it, listState, null, accountViewModel, nav) },
onLoaded = { DraftFeedLoaded(it, listState, accountViewModel, nav) },
)
}
}
@@ -140,7 +109,6 @@ private fun RenderDraftListScreen(
private fun DraftFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -207,7 +175,7 @@ private fun DraftFeedLoaded(
NoteCompose(
item,
modifier = MaterialTheme.colorScheme.maxWidthWithBackground,
routeForLastRead = routeForLastRead,
routeForLastRead = null,
isBoostedNote = false,
isHiddenFeed = items.showHidden,
quotesLeft = 3,
@@ -1,39 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
@Stable
class DraftEventsFeedViewModel(
val account: Account,
) : FeedViewModel(DraftEventsFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = DraftEventsFeedViewModel(account) as T
}
}
@@ -132,7 +132,7 @@ fun GeoHashActionOptions(
tag: String,
accountViewModel: AccountViewModel,
) {
val isFollowingTag by observeUserIsFollowingGeohash(accountViewModel.userProfile(), tag, accountViewModel)
val isFollowingTag by observeUserIsFollowingGeohash(tag, accountViewModel)
if (isFollowingTag) {
UnfollowButton {
@@ -145,7 +145,7 @@ fun HashtagActionOptions(
tag: String,
accountViewModel: AccountViewModel,
) {
val isFollowingTag by observeUserIsFollowingHashtag(accountViewModel.userProfile(), tag, accountViewModel)
val isFollowingTag by observeUserIsFollowingHashtag(tag, accountViewModel)
if (isFollowingTag) {
UnfollowButton {
@@ -98,7 +98,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@@ -174,7 +173,6 @@ private fun NewPostScreenInner(
// function when the postViewModel is released
accountViewModel.runIOCatching {
postViewModel.sendPostSync()
delay(100)
nav.popBack()
}
},
@@ -183,9 +181,9 @@ private fun NewPostScreenInner(
// function when the postViewModel is released
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
nav.popBack()
postViewModel.cancel()
}
nav.popBack()
},
)
},
@@ -146,7 +146,9 @@ open class ShortNotePostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
sendDraftSync()
accountViewModel.runIOCatching {
sendDraftSync()
}
}
}
}
@@ -481,12 +483,14 @@ open class ShortNotePostViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.deleteDraft(version)
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -495,7 +499,7 @@ open class ShortNotePostViewModel :
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -138,10 +138,9 @@ fun NewPublicMessageScreen(
// function when the postViewModel is released
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
delay(100)
nav.popBack()
postViewModel.cancel()
}
nav.popBack()
},
onPost = {
// uses the accountViewModel scope to avoid cancelling this
@@ -149,9 +148,7 @@ fun NewPublicMessageScreen(
accountViewModel.runIOCatching {
postViewModel.sendPostSync()
nav.popBack()
postViewModel.cancel()
}
nav.popBack()
},
)
},
@@ -127,7 +127,9 @@ class NewPublicMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
sendDraftSync()
accountViewModel.runIOCatching {
sendDraftSync()
}
}
}
}
@@ -319,12 +321,14 @@ class NewPublicMessageViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.deleteDraft(version)
accountViewModel.viewModelScope.launch {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val broadcast = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -333,7 +337,7 @@ class NewPublicMessageViewModel :
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, broadcast)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast)
}
}
@@ -47,7 +47,7 @@ fun GalleryCardCompose(
nav: INav,
ratio: Float = 1.0f,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav, shortPreview = true) {
CheckHiddenFeedWatchBlockAndReport(
note = baseNote,
modifier = modifier,
@@ -60,10 +60,10 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
@@ -56,13 +56,13 @@ import com.vitorpamplona.amethyst.model.parseFeatureSetType
import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.mockSharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
@@ -68,6 +68,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.WarningType
import com.vitorpamplona.amethyst.model.parseWarningType
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -75,8 +77,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.AddButton
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel
@@ -395,6 +395,7 @@ fun NoteMaster(
WatchNoteEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav,
modifier,
) {
CheckHiddenFeedWatchBlockAndReport(
@@ -405,7 +406,7 @@ fun NoteMaster(
accountViewModel = accountViewModel,
nav = nav,
) { canPreview ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
FullBleedNoteCompose(
baseNote,
modifier
@@ -552,7 +553,7 @@ private fun FullBleedNoteCompose(
} else if (noteEvent is PictureEvent) {
PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav)
} else if (noteEvent is BaseVoiceEvent) {
VoiceHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
VoiceHeader(noteEvent, baseNote, accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
@@ -167,6 +167,7 @@ val Size39Modifier = Modifier.size(39.dp)
val Size40Modifier = Modifier.size(40.dp)
val Size50Modifier = Modifier.size(50.dp)
val Size55Modifier = Modifier.size(55.dp)
val Size75Modifier = Modifier.size(75.dp)
val TinyBorders = Modifier.padding(2.dp)
val NoSoTinyBorders = Modifier.padding(start = 5.dp, end = 5.dp, top = 2.dp, bottom = 2.dp)
@@ -353,3 +354,7 @@ val SimpleImageBorder = Modifier.fillMaxSize().clip(QuoteBorder)
val SimpleHeaderImage = Modifier.fillMaxWidth().heightIn(max = 200.dp)
val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20))
val MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp)
@@ -46,8 +46,8 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -1010,4 +1010,6 @@
<string name="search_by_hashtag">Vyhledávání hashtag: #%1$s</string>
<string name="dont_translate_from">Nepřekládat z</string>
<string name="dont_translate_from_description">Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit.</string>
<string name="pause">Pozastavit</string>
<string name="play">Hrát</string>
</resources>
@@ -933,4 +933,7 @@
<string name="select_list_to_filter">Vyberte seznam pro filtrování kanálu</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="share_image">Sdílet obrázek…</string>
<string name="option_of">Možnost %1$s z %2$s</string>
<string name="feed_filter_selected">Filtr kanálu, %1$s vybráno</string>
<string name="feed_filter_select_an_option">Filtr kanálu, %1$s</string>
</resources>
@@ -1015,4 +1015,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="search_by_hashtag">Suche Hashtag: #%1$s</string>
<string name="dont_translate_from">Nicht übersetzen von</string>
<string name="dont_translate_from_description">Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen.</string>
<string name="pause">Pausen</string>
<string name="play">Abspielen</string>
</resources>
@@ -973,4 +973,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="blocked_section">Blockierte Relays</string>
<string name="blocked_section_explainer">Amethyst wird sich niemals mit diesen Relays verbinden</string>
<string name="public_message">Öffentliche Nachricht</string>
<string name="option_of">Option %1$s von %2$s</string>
<string name="feed_filter_selected">Feed-Filter, %1$s ausgewählt</string>
<string name="feed_filter_select_an_option">Feed-Filter, %1$s</string>
</resources>
@@ -51,6 +51,13 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">Connectez-vous avec une clé privée pour ne plus Suivre</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">Vous utilisez une clé publique et les clés publiques sont en lecture seule. Connectez-vous avec une clé privée pour pouvoir masquer un mot ou une phrase</string>
<string name="login_with_a_private_key_to_be_able_to_show_word">Vous utilisez une clé publique et les clés publiques sont en lecture seule. Connectez-vous avec une clé privée pour pouvoir afficher un mot ou une phrase</string>
<string name="login_with_a_private_key_to_be_able_to_change_settings">Vous utilisez une clé publique et les clés publiques sont en lecture seule. Connectez-vous avec une clé privée pour pouvoir modifier les paramètres</string>
<string name="login_with_a_private_key_to_be_able_to_upload">Vous utilisez une clé publique et les clés publiques sont en lecture seule. Connectez-vous avec une clé privée pour pouvoir téléverser</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">Vous utilisez une clé publique et les clés publiques sont en lecture seule. Connectez-vous avec une clé privée pour pouvoir signer des événements</string>
<string name="unauthorized_exception">Déchiffrement non autorisé</string>
<string name="unauthorized_exception_description">Le signataire n\'a pas autorisé le décryptage nécessaire pour effectuer cette opération. Activez les décryptions NIP-44 dans votre application signataire et réessayez</string>
<string name="signer_not_found_exception">Signataire introuvable</string>
<string name="signer_not_found_exception_description">L\'application de signature a-t-elle été désinstallée ? Vérifiez si le signataire est installé et a ce compte. Déconnectez-vous et reconnectez-vous de l\'application signataire a changé.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Nombre de vues</string>
<string name="boost">Boost</string>
@@ -71,6 +78,8 @@
<string name="error_parsing_error_message">Erreur d\'analyse du message d\'erreur</string>
<string name="following">" Abonnements"</string>
<string name="followers">" Abonnés"</string>
<string name="number_following">"%1$s abonnements"</string>
<string name="number_followers">"%1$s abonnés"</string>
<string name="profile">Profil</string>
<string name="security_filters">Filtres de sécurité</string>
<string name="log_out">Déconnexion</string>
@@ -96,6 +105,7 @@
<string name="my_awesome_group">Mon groupe spectaculaire</string>
<string name="picture_url">Url de l\'image</string>
<string name="description">Description</string>
<string name="no_description">Description introuvable</string>
<string name="about_us">"à propos…"</string>
<string name="what_s_on_your_mind">Quoi de neuf ?</string>
<string name="write_a_message">Rédiger un message…</string>
@@ -137,6 +147,10 @@
<string name="video_saved_to_the_gallery">Vidéo enregistrée dans la galerie vidéo du téléphone</string>
<string name="failed_to_save_the_video">Impossible d\'enregistrer la vidéo</string>
<string name="upload_image">Charger une image</string>
<string name="take_a_picture">Prendre une photo</string>
<string name="record_a_message">Enregistrer un message</string>
<string name="record_a_message_title">Enregistrer un message</string>
<string name="record_a_message_description">Cliquez et maintenez pour enregistrer un message</string>
<string name="uploading">Chargement…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
<string name="reply_here">"répondre ici"</string>
@@ -155,6 +169,7 @@
<string name="gallery">Galerie</string>
<string name="follows">"Suivis"</string>
<string name="reports">"Signalements"</string>
<string name="number_reports">"%1$s rapports"</string>
<string name="more_options">Plus d\'options</string>
<string name="relays">" Relais"</string>
<string name="website">Site Web</string>
@@ -213,6 +228,13 @@
<string name="unfollow">Ne plus suivre</string>
<string name="channel_created">Canal créé</string>
<string name="channel_information_changed_to">"Les informations du canal ont été remplacées par"</string>
<string name="ephemeral_relay_chat">Discussion éphémère</string>
<string name="relay_chat">Relais de discussion</string>
<string name="relay_chat_title">Relais de discussions</string>
<string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d\'origine.
Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer.
Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères
et, par conséquent, les messages de discussion disparaissent avec le temps</string>
<string name="public_chat">Chat public</string>
<string name="public_chat_title">Métadonnées de tchat public</string>
<string name="public_chat_explainer">Les conversations publiques sont visibles pour tout le monde sur Nostr et n\'importe qui
@@ -221,6 +243,8 @@
<string name="public_chat_relays_title">Relais</string>
<string name="public_chat_relays_explainer">Insérez entre 1 et 3 relais qui hébergent ce groupe.
Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer.</string>
<string name="paid_relay">Relais payant</string>
<string name="tor_relay">Force Tor lors de la connexion</string>
<string name="posts_received">messages reçus</string>
<string name="remove">Retirer</string>
<string name="translations_auto">Auto</string>
@@ -265,6 +289,7 @@
<string name="biometric_error">Erreur</string>
<string name="badge_created_by">"Créé par %1$s"</string>
<string name="badge_award_image_for">"Image de badge pour %1$s"</string>
<string name="badge_award_image">Image du badge de récompense</string>
<string name="new_badge_award_notif">Vous avez reçu un nouveau Badge</string>
<string name="award_granted_to">Le Badge a été décerné à</string>
<string name="copied_note_text_to_clipboard">Copie du contenu de la note dans le presse-papiers</string>
@@ -290,6 +315,7 @@
<string name="quick_action_unfollow">Ne plus suivre</string>
<string name="quick_action_follow">Suivre</string>
<string name="quick_action_request_deletion_gallery_title">Supprimer de la galerie</string>
<string name="quick_action_request_deletion_gallery_alert_body_v2">Supprimer ce média de votre galerie.</string>
<string name="quick_action_request_deletion_alert_title">Demande de Suppression</string>
<string name="quick_action_request_deletion_alert_body">Amethyst demandera que votre note soit supprimée des relais auxquels vous êtes actuellement connecté. Il n\'y a aucune garantie que votre note sera définitivement supprimée de ces relais, ou d\'autres relais où elle peut être stockée.</string>
<string name="quick_action_block_dialog_btn">Bloquer</string>
@@ -411,6 +437,7 @@
<string name="no">Non</string>
<string name="follow_list_selection">Liste des Abonnés</string>
<string name="follow_list_kind3follows">Tous les abonnements</string>
<string name="follow_list_kind3follows_proxy">Suivre via Proxy</string>
<string name="follow_list_aroundme">Autour de moi</string>
<string name="follow_list_global">Général</string>
<string name="follow_list_mute_list">Liste en Sourdine</string>
@@ -485,9 +512,18 @@
<string name="content_warning_hide_all_sensitive_content">Toujours cacher les contenus sensibles</string>
<string name="content_warning_show_all_sensitive_content">Toujours afficher les contenus sensibles</string>
<string name="content_warning_see_warnings">Toujours afficher les avertissements de contenu</string>
<string name="content_warning_hide_all_sensitive_content_option">Masquer</string>
<string name="content_warning_show_all_sensitive_content_option">Afficher</string>
<string name="content_warning_see_warnings_option">Avertir</string>
<string name="recommended_apps">Recommendations: </string>
<string name="filter_spam_from_strangers">Filtrer le spam des inconnus</string>
<string name="warn_when_posts_have_reports_from_your_follows">Avertir lorsque les messages ont été rapportés par vos abonnés</string>
<string name="filter_spam_from_strangers_title">Filtrer les spams</string>
<string name="filter_spam_from_strangers_explainer">Masque les messages d\'inconnus qui étaient exactement les mêmes depuis 5 fois ou plus</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Avertir sur les rapports</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Affiche un message d\'avertissement lorsque des messages ont été rapportés 5 fois ou plus par vos abonnements</string>
<string name="show_sensitive_content_title">Afficher le contenu sensible</string>
<string name="show_sensitive_content_explainer">Affiche un message d\'avertissement lorsque l\'auteur du message l\'a marqué comme sensible</string>
<string name="new_reaction_symbol">Nouveau symbole de réaction</string>
<string name="no_reaction_type_setup_long_press_to_change">Aucun type de réaction sélectionné. Appuyer longuement pour changer</string>
<string name="zapraiser">Collecte de Zaps</string>
@@ -539,7 +575,12 @@
<string name="are_you_sure_you_want_to_log_out">Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ?</string>
<string name="followed_tags">Tags suivis</string>
<string name="relay_setup">Relais</string>
<string name="discover_follows">Packs à suivre</string>
<string name="discover_reads">Lectures</string>
<string name="discover_content_v2">Algorithmes de flux</string>
<string name="discover_marketplace">Place de Marché</string>
<string name="discover_live_v2">Diffusions en direct</string>
<string name="discover_community_v2">Communautés</string>
<string name="discover_chat">Salons</string>
<string name="community_approved_posts">Messages approuvés</string>
<string name="groups_no_descriptor">Ce groupe n\'a pas de description ou de règles. Parlez au propriétaire pour en ajouter une</string>
@@ -547,6 +588,7 @@
<string name="add_sensitive_content_label">Contenu sensible</string>
<string name="add_sensitive_content_description">Ajouter un avertissement de contenu sensible avant de montrer ce contenu</string>
<string name="preferences">Préférences de l\'application</string>
<string name="user_preferences">Préférences utilisateur</string>
<string name="settings">Paramètres</string>
<string name="connectivity_type_always">Toujours</string>
<string name="connectivity_type_wifi_only">Wifi uniquement</string>
@@ -591,6 +633,8 @@
<string name="geohash_explainer">Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel</string>
<string name="geohash_exclusive">Post exclusif à la localisation</string>
<string name="geohash_exclusive_explainer">Seuls les abonnés à cette localisation le verront. Vos abonnés généraux ne le verront pas.</string>
<string name="hashtag_exclusive">Message à hashtag-exclusif</string>
<string name="hashtag_exclusive_explainer">Seuls les abonnés du hashtag le verront. Vos abonnés généraux ne le verront pas.</string>
<string name="loading_location">Chargement de la localisation</string>
<string name="lack_location_permissions">Pas d\'autorisations de localisation</string>
<string name="add_sensitive_content_explainer">Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant</string>
@@ -599,6 +643,7 @@
<string name="new_feature_nip17_activate">Activer</string>
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_public_private_chat_description">Nouveau Groupe Public ou Privé</string>
<string name="messages_relay_based">Relais</string>
<string name="messages_new_message">Privé</string>
<string name="messages_new_message_to">À</string>
<string name="messages_new_message_subject">Sujet</string>
@@ -664,6 +709,7 @@
<string name="wallet_number">Portefeuille %1$s</string>
<string name="error_opening_external_signer">Erreur lors de l\'ouverture de l\'application de signature</string>
<string name="error_opening_external_signer_description">L\'application signataire est introuvable. Vérifiez que l\'application n\'a pas été désinstallée</string>
<string name="sign_request_rejected2">Requête de signature rejetée</string>
<string name="sign_request_rejected">Demande de signature rejetée</string>
<string name="sign_request_rejected_description">Assurez-vous que l\'application signataire a autorisé cette transaction</string>
<string name="no_wallet_found_with_error">Aucun portefeuille trouvé pour payer une facture lightning (Erreur : %1$s). Veuillez installer un portefeuille Lightning pour utiliser les zaps</string>
@@ -791,6 +837,8 @@
<string name="new_post">Nouveau Message</string>
<string name="new_short">Nouveaux Shorts : images ou vidéos</string>
<string name="new_community_note">Nouvelle Note Communautaire</string>
<string name="new_product">Nouveau produit</string>
<string name="new_exclusive_geo_note">Nouveau message géo-exclusif</string>
<string name="open_all_reactions_to_this_post">Ouvrir toutes les réactions à ce message</string>
<string name="close_all_reactions_to_this_post">Fermer toutes les réactions à ce message</string>
<string name="reply_description">Répondre</string>
@@ -845,12 +893,29 @@
<string name="private_outbox_section_explainer">Insérez entre 1 et 3 relais pour stocker des événements que personne d\'autre ne peut voir, comme vos Brouillons et/ou paramètres d\'application. Idéalement, ces relais sont soit locaux soit nécessitent une authentification avant de télécharger le contenu de chaque utilisateur.</string>
<string name="kind_3_section">Relais généraux</string>
<string name="kind_3_section_description">Amethyst utilise ces relais pour télécharger des messages pour vous.</string>
<string name="connected_section">Relais connectés</string>
<string name="connected_section_description">Liste actuelle des relais utilisés</string>
<string name="kind_3_recommended_section">Relais recommandés</string>
<string name="kind_3_recommended_section_description">Ajoutez les relais suivants à votre liste de relais généraux afin de recevoir les messages des utilisateurs listés.</string>
<string name="search_section">Relais de recherche</string>
<string name="search_section_explainer">Liste des relais à utiliser pour la recherche et l\'étiquetage des utilisateurs. L\'étiquetage et la recherche ne fonctionneront pas si aucune option n\'est disponible.</string>
<string name="local_section">Relais locaux</string>
<string name="local_section_explainer">Liste des relais qui sont en cours d\'exécution sur cet appareil.</string>
<string name="trusted_relays_title">Relais de confiance</string>
<string name="trusted_section">Relais de confiance</string>
<string name="trusted_section_explainer">Relais de confiance ne nécessitant pas une connexion Tor pour</string>
<string name="proxy_relays_title">Relais Proxy</string>
<string name="proxy_section">Relais Proxy</string>
<string name="proxy_section_explainer">Relais agrégateurs que l\'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d\'envoi et permettra à l\'application de se connecter uniquement aux relais de vos listes.</string>
<string name="broadcast_relays_title">Relais de diffusion</string>
<string name="broadcast_section">Relais de diffusion</string>
<string name="broadcast_section_explainer">Relais qui se spécialisent dans la publication de vos notes à tous les autres relais, comme sendit.nosflare.com. Amethyst ajoutera ce relais à tous vos nouveaux événements</string>
<string name="indexer_relays_title">Relais indexeur</string>
<string name="indexer_section">Relais indexeur</string>
<string name="indexer_section_explainer">Relais qui se spécialisent dans l\'hébergement des métadonnées et des listes de relais de chacun, comme purplepag.es. Amethyst utilisera ces relais pour trouver des utilisateurs qui ne sont pas dans vos listes.</string>
<string name="blocked_relays_title">Relais bloqués</string>
<string name="blocked_section">Relais bloqués</string>
<string name="blocked_section_explainer">Amethyst ne se connectera jamais à ces relais</string>
<string name="zap_the_devs_title">Zap les Devs !</string>
<string name="zap_the_devs_description">Votre don nous aide à faire la différence. Chaque sat compte !</string>
<string name="donate_now">Faire un don maintenant</string>
@@ -940,4 +1005,13 @@
<string name="select_list_to_filter">Sélectionnez une liste pour filtrer le fil</string>
<string name="temporary_account">Se déconnecter au verrouillage de l\'appareil</string>
<string name="private_message">Message privé</string>
<string name="public_message">Message public</string>
<string name="group_relay">Relais de chat</string>
<string name="group_relay_explanation">Le relais auquel tous les utilisateurs de ce chat se connectent</string>
<string name="share_image">Partager l\'image…</string>
<string name="search_by_hashtag">Hashtag de recherche : #%1$s</string>
<string name="dont_translate_from">Ne pas traduire depuis</string>
<string name="dont_translate_from_description">Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau.</string>
<string name="pause">Pause</string>
<string name="play">Lecture</string>
</resources>
@@ -1012,4 +1012,6 @@
<string name="search_by_hashtag">विषयसूचक खोज : #%1$s</string>
<string name="dont_translate_from">अनुवाद ना करें</string>
<string name="dont_translate_from_description">यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा।</string>
<string name="pause">विराम</string>
<string name="play">चलाएँ</string>
</resources>
@@ -1012,4 +1012,6 @@
<string name="search_by_hashtag">Hashtag keresése: #%1$s</string>
<string name="dont_translate_from">Innentől NE fordítsa le</string>
<string name="dont_translate_from_description">Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet.</string>
<string name="pause">Szüneteltetés</string>
<string name="play">Lejátszás</string>
</resources>
@@ -1012,4 +1012,6 @@
<string name="search_by_hashtag">Zoek hashtag: #%1$s</string>
<string name="dont_translate_from">Niet vertalen van</string>
<string name="dont_translate_from_description">Talen die hier getoond worden worden worden niet vertaald. Selecteer een taal om te verwijderen en het opnieuw te laten vertalen.</string>
<string name="pause">Pauze</string>
<string name="play">Afspelen</string>
</resources>
@@ -162,7 +162,7 @@
<string name="new_requests">Nowe Kontakty</string>
<string name="blocked_users">Zablokowani użytkownicy</string>
<string name="new_threads">Nowe Wpisy</string>
<string name="conversations">Konwersacje</string>
<string name="conversations">Komentarze</string>
<string name="notes">Wpisy</string>
<string name="replies">Odpowiedzi</string>
<string name="mutual">Twoje</string>
@@ -572,9 +572,9 @@
<string name="are_you_sure_you_want_to_log_out">Wylogowanie usuwa wszystkie informacje lokalne. Upewnij się, że masz kopię zapasową kluczy prywatnych, aby uniknąć utraty konta. Czy chcesz kontynuować?</string>
<string name="followed_tags">Obserwowane tagi</string>
<string name="relay_setup">Transmitery</string>
<string name="discover_follows">Obserwowani</string>
<string name="discover_reads">Wyświetlenia</string>
<string name="discover_content_v2">Algorytmy kanału</string>
<string name="discover_follows">Godne uwagi</string>
<string name="discover_reads">Popularne</string>
<string name="discover_content_v2">Wybrane przez algorytm</string>
<string name="discover_marketplace">Market</string>
<string name="discover_live_v2">Transmisja na żywo</string>
<string name="discover_community_v2">Społeczności</string>
@@ -1009,4 +1009,6 @@
<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>
<string name="pause">Pauza</string>
<string name="play">Odtwórz</string>
</resources>
@@ -247,8 +247,8 @@
<string name="tor_relay">Forçar uso do Tor ao conectar</string>
<string name="posts_received">postagens recebidas</string>
<string name="remove">Remover</string>
<string name="translations_auto">Automaticamente </string>
<string name="translations_translated_from">traduzido de </string>
<string name="translations_auto">Autotraduzido </string>
<string name="translations_translated_from"> de </string>
<string name="translations_to">para</string>
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
<string name="chat_about_topic">Chat público sobre %1$s</string>
@@ -1010,4 +1010,9 @@
<string name="search_by_hashtag">Pesquisar hashtag: #%1$s</string>
<string name="dont_translate_from">Não Traduzir de</string>
<string name="dont_translate_from_description">Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente.</string>
<string name="pause">Pausar</string>
<string name="play">Reproduzir</string>
<string name="option_of">Opção %1$s de %2$s</string>
<string name="feed_filter_selected">Filtro de feed, %1$s selecionado</string>
<string name="feed_filter_select_an_option">Filtro de feed, %1$s</string>
</resources>
@@ -1009,4 +1009,9 @@
<string name="search_by_hashtag">Sök hashtag: #%1$s</string>
<string name="dont_translate_from">Översätt inte från</string>
<string name="dont_translate_from_description">Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen.</string>
<string name="pause">Pausa</string>
<string name="play">Spela</string>
<string name="option_of">Alternativ %1$s av %2$s</string>
<string name="feed_filter_selected">Flödesfilter, %1$s valt</string>
<string name="feed_filter_select_an_option">Flödesfilter, %1$s</string>
</resources>
+29 -27
View File
@@ -51,13 +51,13 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">你正在使用公钥,公钥是只读的。使用私钥登录以便能够取消关注</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">你正在使用公钥,公钥是只读的。使用私钥登录以便能够隐藏单词或句子</string>
<string name="login_with_a_private_key_to_be_able_to_show_word">你正在使用公钥,公钥是只读的。使用私钥登录以便能够显示单词或句子</string>
<string name="login_with_a_private_key_to_be_able_to_change_settings">正在使用公钥,公钥是只读的。要更改设置请用私钥登录</string>
<string name="login_with_a_private_key_to_be_able_to_upload">正在使用公钥,公钥是只读的。要上传请使用私钥登录</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">正在使用公钥,公钥是只读的。要报名参加活动请使用私钥登录</string>
<string name="login_with_a_private_key_to_be_able_to_change_settings">正在使用公钥以只读方式登录,需要使用私钥登录才能更改设置。</string>
<string name="login_with_a_private_key_to_be_able_to_upload">正在使用公钥以只读方式登录,需要使用私钥登录才能上传。</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">正在使用公钥以只读方式登录,需要使用私钥登录才能签署事件。</string>
<string name="unauthorized_exception">未授权的解密</string>
<string name="unauthorized_exception_description">签名没有授权进行此操作所需的解密。在签名人应用中激活 NIP-44 解密并重试</string>
<string name="signer_not_found_exception">未找到签名</string>
<string name="signer_not_found_exception_description">签名人应用被卸载了吗?检查是否安装了签名以及是否签名人有该账户。注销并再次登录,签名人应用已更改</string>
<string name="unauthorized_exception_description">签名没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试</string>
<string name="signer_not_found_exception">未找到签名</string>
<string name="signer_not_found_exception_description">签名器被卸载?请检查是否已经安装了签名以及其中是否存在该账户。变更签名器需要注销后重新登录</string>
<string name="zaps">打闪</string>
<string name="view_count">浏览次数</string>
<string name="boost">提升</string>
@@ -150,7 +150,7 @@
<string name="take_a_picture">拍照</string>
<string name="record_a_message">录制消息</string>
<string name="record_a_message_title">录制消息</string>
<string name="record_a_message_description">单击并按住来录制消息</string>
<string name="record_a_message_description">点按以录制消息</string>
<string name="uploading">上传中…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
<string name="reply_here">"🔏在此回复… "</string>
@@ -231,10 +231,10 @@
<string name="ephemeral_relay_chat">临时聊天</string>
<string name="relay_chat">中继聊天</string>
<string name="relay_chat_title">中继聊天</string>
<string name="relay_chat_explainer">中继聊天是由中继控制的聊天群。
它们对 Nostr 上的所有人可见,任何人均可加入它们
它们对围绕特定话题的开放社区很好。这些群中的一些是临时存在的
而,聊天息会随着时间消失</string>
<string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群
Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组
这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的,
聊天息会随着时间的推移而消失</string>
<string name="public_chat">公共聊天</string>
<string name="public_chat_title">公开聊天元数据</string>
<string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以:
@@ -244,7 +244,7 @@
<string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。
让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string>
<string name="paid_relay">付费中继</string>
<string name="tor_relay">连接时强制使用 Tor</string>
<string name="tor_relay">强制使用 Tor 进行连接</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="translations_auto">自动</string>
@@ -289,7 +289,7 @@
<string name="biometric_error">错误</string>
<string name="badge_created_by">"由 %1$s 创建"</string>
<string name="badge_award_image_for">"颁发给 %1$s 的徽章图片"</string>
<string name="badge_award_image">\"徽章奖品图片</string>
<string name="badge_award_image">\"徽章图片</string>
<string name="new_badge_award_notif">你收到了新的徽章奖励</string>
<string name="award_granted_to">徽章奖励授予</string>
<string name="copied_note_text_to_clipboard">文本已复制到剪贴板</string>
@@ -577,7 +577,7 @@
<string name="relay_setup">中继器</string>
<string name="discover_follows">关注包</string>
<string name="discover_reads">次浏览</string>
<string name="discover_content_v2">Feed算法</string>
<string name="discover_content_v2">动态源算法</string>
<string name="discover_marketplace">市场</string>
<string name="discover_live_v2">直播</string>
<string name="discover_community_v2">社区</string>
@@ -633,8 +633,8 @@
<string name="geohash_explainer">将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里)</string>
<string name="geohash_exclusive">位置限定帖子</string>
<string name="geohash_exclusive_explainer">只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。</string>
<string name="hashtag_exclusive">话题标签专属帖子</string>
<string name="hashtag_exclusive_explainer">只有话题标签的关注者才会看到它。您的一般关注者不会看到</string>
<string name="hashtag_exclusive">话题限定贴文</string>
<string name="hashtag_exclusive_explainer">只有关注了话题的人才能看到贴文,您的普通关注者也无法看到。</string>
<string name="loading_location">加载位置中</string>
<string name="lack_location_permissions">没有位置信息权限</string>
<string name="add_sensitive_content_explainer">在显示你的内容之前添加敏感的内容警告。针对任何 NSFW 内容或一些人可能觉得有冒犯性或令人不安的内容。</string>
@@ -709,7 +709,7 @@
<string name="wallet_number">钱包 %1$s</string>
<string name="error_opening_external_signer">打开签名应用时出错</string>
<string name="error_opening_external_signer_description">找不到签名应用。检查应用是否已被卸载</string>
<string name="sign_request_rejected2">签名请求被拒绝</string>
<string name="sign_request_rejected2">签名请求被拒绝</string>
<string name="sign_request_rejected">签名请求被拒绝了</string>
<string name="sign_request_rejected_description">请确保签名应用程序已授权此交易</string>
<string name="no_wallet_found_with_error">找不到支付闪电发票的钱包(错误:%1$s)。请安装闪电钱包来使用打闪</string>
@@ -838,7 +838,7 @@
<string name="new_short">新短篇媒体:图像或视频</string>
<string name="new_community_note">新社区笔记</string>
<string name="new_product">新产品</string>
<string name="new_exclusive_geo_note">新建地理位置专属帖子</string>
<string name="new_exclusive_geo_note">新建地理位置限定帖文</string>
<string name="open_all_reactions_to_this_post">展开对此帖子的所有回应</string>
<string name="close_all_reactions_to_this_post">收起对此帖子的所有回应</string>
<string name="reply_description">回复</string>
@@ -903,19 +903,19 @@
<string name="local_section_explainer">在该设备中运行的中继列表。</string>
<string name="trusted_relays_title">可信中继</string>
<string name="trusted_section">可信中继</string>
<string name="trusted_section_explainer">你信任的无需 Tor 连接的中继</string>
<string name="trusted_section_explainer">无需使用 Tor 进行连接的可信中继</string>
<string name="proxy_relays_title">代理中继</string>
<string name="proxy_section">代理中继</string>
<string name="proxy_section_explainer">聚合器中继下载 feed 必须使用的应用的流量,类似 filter.nostr.wine。这替代 outbox 模型,让应用只连接到列表中的中继。</string>
<string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只连接到这个列表中的中继以获取内容</string>
<string name="broadcast_relays_title">广播中继</string>
<string name="broadcast_section">广播中继</string>
<string name="broadcast_section_explainer">专门推送便笩到所有其他中继的中继,类似 sendit.nosflare.com。Amethyst 会将这个中继添加到所有你所做的新事件</string>
<string name="broadcast_section_explainer">推送你的贴文到其他中继的中继(例如:sendit.nosflare.com),应用会把这个中继作为提示嵌入到你发送的所有事件的 JSON 结构中。</string>
<string name="indexer_relays_title">索引器中继</string>
<string name="indexer_section">索引器中继</string>
<string name="indexer_section_explainer">专门托管每个人的元数据和中继列表的中继,类似 purplepag.es。Amethyst将使用这些中继来查找不在列表中的用户</string>
<string name="blocked_relays_title">屏蔽的中继</string>
<string name="blocked_section">屏蔽的中继</string>
<string name="blocked_section_explainer">Amethyst 永远不会连接到这些中继</string>
<string name="indexer_section_explainer">专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在列表中的账户元数据</string>
<string name="blocked_relays_title">中继黑名单</string>
<string name="blocked_section">中继黑名单</string>
<string name="blocked_section_explainer">应用永远不会连接中继</string>
<string name="zap_the_devs_title">打闪开发人员!</string>
<string name="zap_the_devs_description">你的捐赠帮助我们做出不同的贡献。每个聪都很重要!</string>
<string name="donate_now">立即捐款</string>
@@ -1010,6 +1010,8 @@
<string name="group_relay_explanation">此聊天所有用户都连接到的中继</string>
<string name="share_image">分享图片…</string>
<string name="search_by_hashtag">搜索话题标签:#%1$s</string>
<string name="dont_translate_from">不要翻译</string>
<string name="dont_translate_from_description">此处显示的语言不会被翻译请选择一种语言来移除它并重新翻译它</string>
<string name="dont_translate_from">不要翻译</string>
<string name="dont_translate_from_description">此处显示的语言不会被翻译请选择一种目标语言重新翻译并去除这个提示</string>
<string name="pause">暂停</string>
<string name="play">播放</string>
</resources>
+7 -1
View File
@@ -1,5 +1,5 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name_release" translatable="false">Amethyst</string>
<string name="app_name" translatable="false">Amethyst</string>
<string name="app_name_debug" translatable="false">Amy Debug</string>
<string name="app_name_benchmark" translatable="false">Amy Benchmark</string>
<string name="point_to_the_qr_code">Point to the QR Code</string>
@@ -1230,4 +1230,10 @@
<string name="dont_translate_from">Don\'t Translate From</string>
<string name="dont_translate_from_description">Languages shown here will not be translated. Select a language to remove it and have it translated again.</string>
<string name="pause">Pause</string>
<string name="play">Play</string>
<string name="open_dropdown_menu">Open dropdown menu</string>
<string name="option_of">Option %1$s of %2$s</string>
<string name="feed_filter_selected">Feed filter, %1$s selected</string>
<string name="feed_filter_select_an_option">Feed filter, %1$s</string>
</resources>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.service.retryIfException
@@ -35,12 +36,16 @@ object PushNotificationUtils {
accounts: List<AccountInfo>,
okHttpClient: (String) -> OkHttpClient,
) = with(Dispatchers.IO) {
val token = FirebaseMessaging.getInstance().token.await()
if (hasInit?.equals(accounts) == true && lastToken == token) {
return@with
}
try {
val token = FirebaseMessaging.getInstance().token.await()
if (hasInit?.equals(accounts) == true && lastToken == token) {
return@with
}
registerToken(token, accounts, okHttpClient)
registerToken(token, accounts, okHttpClient)
} catch (e: Exception) {
Log.e("PushNotificationUtils", "Failed to get Firebase token", e)
}
}
suspend fun checkAndInit(
@@ -95,6 +95,14 @@ class RelayUrlNormalizer {
@OptIn(ExperimentalContracts::class)
fun fix(url: String): String? {
if (url.length < 3) return null
if (url.length > 100) {
// removes multiple urls in the same line
val schemeIdx = url.indexOf("://")
val nextScheme = url.indexOf("://", schemeIdx + 3)
if (nextScheme > 0) {
return null
}
}
val trimmed =
if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) {
@@ -60,7 +60,7 @@ class ChannelCreateEvent(
val newInfo =
try {
if (isEncrypted()) {
if (content.isEmpty() || !content.startsWith("{") || isEncrypted()) {
ChannelDataNorm()
} else {
ChannelData.parse(content)?.normalize() ?: ChannelDataNorm()
@@ -66,7 +66,7 @@ class ChannelMetadataEvent(
val newInfo =
try {
if (isEncrypted()) {
if (content.isEmpty() || !content.startsWith("{") || isEncrypted()) {
ChannelDataNorm()
} else {
ChannelData.parse(content)?.normalize() ?: ChannelDataNorm()
+1 -5
View File
@@ -6,11 +6,6 @@ amethyst:
builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0
repository: https://github.com/vitorpamplona/amethyst
artifacts:
- amethyst-fdroid-arm64-v8a-v%v.apk
- amethyst-fdroid-armeabi-v7a-v%v.apk
- amethyst-fdroid-universal-v%v.apk
- amethyst-fdroid-x86-v%v.apk
- amethyst-fdroid-x86_64-v%v.apk
- amethyst-googleplay-arm64-v8a-v%v.apk
- amethyst-googleplay-armeabi-v7a-v%v.apk
- amethyst-googleplay-universal-v%v.apk
@@ -19,3 +14,4 @@ amethyst: