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

This commit is contained in:
KotlinGeekDev
2025-09-15 18:39:36 +00:00
committed by GitHub
47 changed files with 996 additions and 223 deletions
+1
View File
@@ -17,6 +17,7 @@
/.idea/ChatHistory_schema_v2.xml
/.idea/artifacts/*
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
.DS_Store
/build
/captures
@@ -50,21 +50,19 @@ import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
@Preview
@Composable
fun RenderHashTagIconsPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav)
is HashTagSegment -> HashTag(word, EmptyNav)
is RegularTextSegment -> Text(word.segmentText)
}
}
@@ -0,0 +1,137 @@
/**
* 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.preferences
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.save
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
@Stable
class TorSharedPreferences(
val context: Context,
val scope: CoroutineScope,
) {
companion object {
// loads faster when individualized
val TOR_TYPE_KEY = stringPreferencesKey("tor.torType")
val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort")
val ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor")
val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor")
val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor")
val TRUSTED_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.trustedRelaysViaTor")
val URL_PREVIEWS_VIA_TOR_KEY = booleanPreferencesKey("tor.urlPreviewsViaTor")
val PROFILE_PICS_VIA_TOR_KEY = booleanPreferencesKey("tor.profilePicsViaTor")
val IMAGES_VIA_TOR_KEY = booleanPreferencesKey("tor.imagesViaTor")
val VIDEOS_VIA_TOR_KEY = booleanPreferencesKey("tor.videosViaTor")
val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor")
val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor")
val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor")
}
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value =
runBlocking {
TorSettingsFlow.build(torPreferences() ?: TorSettings())
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun torPreferences(): TorSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
TorSettings(
torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL,
externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050,
onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true,
dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true,
newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true,
trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false,
urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false,
profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false,
imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false,
videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false,
moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false,
nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false,
nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(torSettings: TorSettings) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[TOR_TYPE_KEY] = torSettings.torType.name
preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort
preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor
preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor
preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor
preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor
preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor
preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor
preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor
preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor
preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor
preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor
preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@@ -27,16 +27,18 @@ import androidx.compose.runtime.Stable
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -48,6 +50,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
@@ -57,7 +60,18 @@ class UiSharedPreferences(
val scope: CoroutineScope,
) {
companion object {
val UI_SETTINGS = stringPreferencesKey("ui_settings")
// loads faster when individualized
val UI_THEME = stringPreferencesKey("ui.theme")
val UI_LANGUAGE = stringPreferencesKey("ui.language")
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector")
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
}
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
@@ -96,84 +110,54 @@ class UiSharedPreferences(
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
val newVersion = preferences[UI_SETTINGS]?.let { JsonMapper.mapper.readValue<UiSettings>(it) }
if (newVersion != null) {
newVersion
} else {
UiSettings(
theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM,
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false,
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
try {
val oldVersion = LocalPreferences.loadSharedSettings()
if (oldVersion != null) {
save(oldVersion)
}
oldVersion
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(sharedSettings: UiSettings) {
try {
val str = JsonMapper.mapper.writeValueAsString(sharedSettings)
context.sharedPreferencesDataStore.edit { preferences ->
preferences[UI_SETTINGS] = str
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@Stable
class TorSharedPreferences(
val context: Context,
val scope: CoroutineScope,
) {
companion object {
val TOR_SETTINGS = stringPreferencesKey("tor_settings")
}
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value =
runBlocking {
TorSettingsFlow.build(torPreferences() ?: TorSettings())
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun torPreferences(): TorSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
preferences[TOR_SETTINGS]?.let {
JsonMapper.mapper.readValue<TorSettings>(it)
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(torSettings: TorSettings) {
try {
val str = JsonMapper.mapper.writeValueAsString(torSettings)
context.sharedPreferencesDataStore.edit { preferences ->
preferences[TOR_SETTINGS] = str
preferences[UI_THEME] = sharedSettings.theme.name
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
@@ -47,11 +47,9 @@ class LocationFlow(
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d("LocationFlow", "onLocationChanged $location")
launch { send(location) }
}
LocationListener { location ->
Log.d("LocationFlow", "onLocationChanged $location")
launch { send(location) }
}
locationManager.allProviders.forEach {
@@ -105,7 +105,7 @@ fun VideoView(
val automaticallyStartPlayback =
remember {
mutableStateOf<Boolean>(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value,
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(),
)
}
@@ -0,0 +1,225 @@
/**
* 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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import kotlinx.collections.immutable.ImmutableList
private const val ASPECT_RATIO = 4f / 3f
private val IMAGE_SPACING: Dp = Size5dp
@Composable
private fun GalleryImage(
image: MediaUrlImage,
allImages: ImmutableList<MediaUrlImage>,
modifier: Modifier = Modifier,
roundedCorner: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
Box(modifier = modifier) {
ZoomableContentView(
content = image,
images = allImages,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
}
@Composable
fun ImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
roundedCorner: Boolean = true,
) {
if (images.isEmpty()) return
Column(modifier = modifier.padding(vertical = Size10dp)) {
when (images.size) {
1 -> SingleImageGallery(images, accountViewModel, roundedCorner)
2 -> TwoImageGallery(images, accountViewModel, roundedCorner)
3 -> ThreeImageGallery(images, accountViewModel, roundedCorner)
4 -> FourImageGallery(images, accountViewModel, roundedCorner)
else -> ManyImageGallery(images, accountViewModel, roundedCorner)
}
}
}
@Composable
private fun SingleImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
roundedCorner: Boolean,
) {
GalleryImage(
image = images.first(),
allImages = images,
modifier = Modifier.fillMaxWidth(),
roundedCorner = roundedCorner,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@Composable
private fun TwoImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
roundedCorner: Boolean,
) {
Row(
modifier = Modifier.aspectRatio(ASPECT_RATIO),
horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING),
) {
images.take(2).forEach { image ->
GalleryImage(
image = image,
allImages = images,
modifier = Modifier.weight(1f).fillMaxSize(),
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
}
@Composable
private fun ThreeImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
roundedCorner: Boolean,
) {
Row(
modifier = Modifier.aspectRatio(ASPECT_RATIO),
horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING),
) {
GalleryImage(
image = images[0],
allImages = images,
modifier = Modifier.weight(2f).fillMaxSize(),
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
Column(
modifier = Modifier.weight(1f).fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING),
) {
images.drop(1).forEach { image ->
GalleryImage(
image = image,
allImages = images,
modifier = Modifier.weight(1f).fillMaxSize(),
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
}
}
@Composable
private fun FourImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
roundedCorner: Boolean,
) {
Column(
modifier = Modifier.aspectRatio(ASPECT_RATIO),
verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING),
) {
images.chunked(2).forEach { rowImages ->
Row(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING),
) {
rowImages.forEach { image ->
GalleryImage(
image = image,
allImages = images,
modifier = Modifier.weight(1f).fillMaxSize(),
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
}
}
}
@Composable
private fun ManyImageGallery(
images: ImmutableList<MediaUrlImage>,
accountViewModel: AccountViewModel,
roundedCorner: Boolean,
) {
val columns =
when {
images.size <= 9 -> 3
else -> 4
}
Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
images.chunked(columns).forEach { rowImages ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Size5dp),
) {
rowImages.forEach { image ->
GalleryImage(
image = image,
allImages = images,
modifier = Modifier.weight(1f).aspectRatio(1f),
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
repeat(columns - rowImages.size) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
@@ -39,7 +39,7 @@ fun LoadUrlPreview(
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
if (!accountViewModel.settings.showUrlPreview.value) {
if (!accountViewModel.settings.showUrlPreview()) {
ClickableUrl(urlText, url)
} else {
LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel)
@@ -57,7 +57,7 @@ fun MyAsyncImage(
onError: (@Composable () -> Unit)?,
) {
val ratio = MediaAspectRatioCache.get(imageUrl)
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages.value) }
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
@@ -0,0 +1,272 @@
/**
* 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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RenderContext(
val state: RichTextViewerState,
val backgroundColor: MutableState<Color>,
val quotesLeft: Int,
val callbackUri: String?,
val accountViewModel: AccountViewModel,
val nav: INav,
)
data class ParagraphImageAnalysis(
val imageCount: Int,
val isImageOnly: Boolean,
val hasMultipleImages: Boolean,
)
class ParagraphParser {
fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis {
var imageCount = 0
var hasNonWhitespaceNonImageContent = false
paragraph.words.forEach { word ->
when (word) {
is ImageSegment, is Base64Segment -> imageCount++
is RegularTextSegment -> {
if (word.segmentText.isNotBlank()) {
hasNonWhitespaceNonImageContent = true
}
}
else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc.
}
}
val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent
val hasMultipleImages = imageCount > 1
return ParagraphImageAnalysis(
imageCount = imageCount,
isImageOnly = isImageOnly,
hasMultipleImages = hasMultipleImages,
)
}
fun collectConsecutiveImageParagraphs(
paragraphs: ImmutableList<ParagraphState>,
startIndex: Int,
): Pair<List<ParagraphState>, Int> {
val imageParagraphs = mutableListOf<ParagraphState>()
var j = startIndex
while (j < paragraphs.size) {
val currentParagraph = paragraphs[j]
val words = currentParagraph.words
// Fast path for empty check
if (words.isEmpty()) {
j++
continue
}
// Check for single whitespace word
if (words.size == 1) {
val firstWord = words.first()
if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) {
j++
continue
}
}
// Check if it's an image-only paragraph using unified analysis
val analysis = analyzeParagraphImages(currentParagraph)
if (analysis.isImageOnly) {
imageParagraphs.add(currentParagraph)
j++
} else {
break
}
}
return imageParagraphs to j
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun processParagraph(
paragraphs: ImmutableList<ParagraphState>,
paragraphIndex: Int,
spaceWidth: Dp,
context: RenderContext,
renderSingleParagraph: @Composable (ParagraphState, ImmutableList<Segment>, Dp, RenderContext) -> Unit,
renderImageGallery: @Composable (ImmutableList<Segment>, RenderContext) -> Unit,
): Int {
val paragraph = paragraphs[paragraphIndex]
if (paragraph.words.isEmpty()) {
// Empty paragraph - render normally with FlowRow (will render nothing)
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
val analysis = analyzeParagraphImages(paragraph)
if (analysis.isImageOnly) {
// Collect consecutive image-only paragraphs for gallery
val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex)
val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList()
if (allImageWords.size > 1) {
// Multiple images - render as gallery (no FlowRow wrapper needed)
renderImageGallery(allImageWords, context)
} else {
// Single image - render with FlowRow wrapper
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
}
return endIndex // Return next index to process
} else if (analysis.hasMultipleImages) {
// Mixed paragraph with multiple images - use renderImageGallery for smart grouping
renderImageGallery(paragraph.words.toImmutableList(), context)
return paragraphIndex + 1
} else {
// Regular paragraph (no images or single image) - render normally with FlowRow
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
}
@Composable
fun ProcessWordsWithImageGrouping(
words: ImmutableList<Segment>,
context: RenderContext,
renderSingleWord: @Composable (Segment, RenderContext) -> Unit,
renderGallery: @Composable (ImmutableList<MediaUrlImage>, AccountViewModel) -> Unit,
) {
var i = 0
val n = words.size
while (i < n) {
val word = words[i]
if (word is ImageSegment || word is Base64Segment) {
// Collect consecutive image/whitespace segments without extra list allocations
val imageSegments = mutableListOf<Segment>()
var j = i
while (j < n) {
val seg = words[j]
when {
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
else -> break
}
j++
}
if (imageSegments.size > 1) {
val imageContents =
imageSegments
.mapNotNull { segment ->
val imageUrl = segment.segmentText
context.state.imagesForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
if (imageContents.isNotEmpty()) {
renderGallery(imageContents, context.accountViewModel)
}
} else {
renderSingleWord(imageSegments.firstOrNull() ?: word, context)
}
i = j // jump past processed run
} else {
renderSingleWord(word, context)
i++
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderSingleParagraphWithFlowRow(
paragraph: ParagraphState,
words: ImmutableList<Segment>,
spaceWidth: Dp,
context: RenderContext,
renderWord: @Composable (Segment, RenderContext) -> Unit,
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
},
LocalTextStyle provides LocalTextStyle.current,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
words.forEach { word ->
renderWord(word, context)
}
}
}
}
@Composable
fun ProcessAllParagraphs(
paragraphs: ImmutableList<ParagraphState>,
spaceWidth: Dp,
context: RenderContext,
renderSingleParagraph: @Composable (ParagraphState, ImmutableList<Segment>, Dp, RenderContext) -> Unit,
renderImageGallery: @Composable (ImmutableList<Segment>, RenderContext) -> Unit,
) {
var i = 0
while (i < paragraphs.size) {
i =
processParagraph(
paragraphs = paragraphs,
paragraphIndex = i,
spaceWidth = spaceWidth,
context = context,
renderSingleParagraph = renderSingleParagraph,
renderImageGallery = renderImageGallery,
)
}
}
}
@@ -107,6 +107,8 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -143,8 +145,6 @@ fun RichTextViewer(
@Preview
@Composable
fun RenderStrangeNamePreview() {
val nav = EmptyNav
Column(modifier = Modifier.padding(10.dp)) {
RenderRegular(
"If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here",
@@ -167,7 +167,6 @@ fun RenderStrangeNamePreview() {
@Composable
fun RenderRegularPreview() {
val nav = EmptyNav
val accountViewModel = mockAccountViewModel()
Column(modifier = Modifier.padding(10.dp)) {
RenderRegular(
@@ -194,7 +193,7 @@ fun RenderRegularPreview() {
)
}
is HashTagSegment -> HashTag(word, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -208,7 +207,7 @@ fun RenderRegularPreview() {
@Composable
fun RenderRegularPreview2() {
val nav = EmptyNav
val accountViewModel = mockAccountViewModel()
RenderRegular(
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
EmptyTagList,
@@ -223,7 +222,7 @@ fun RenderRegularPreview2() {
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -264,7 +263,7 @@ fun RenderRegularPreview3() {
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -284,18 +283,10 @@ private fun RenderRegular(
accountViewModel: AccountViewModel,
nav: INav,
) {
RenderRegular(content, tags, callbackUri) { word, state ->
if (canPreview) {
RenderWordWithPreview(
word,
state,
backgroundColor,
quotesLeft,
callbackUri,
accountViewModel,
nav,
)
} else {
if (canPreview) {
RenderRegularWithGallery(content, tags, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav)
} else {
RenderRegular(content, tags, callbackUri) { word, state ->
RenderWordWithoutPreview(
word,
state,
@@ -307,6 +298,51 @@ private fun RenderRegular(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderRegularWithGallery(
content: String,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
quotesLeft: Int,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) }
val context =
RenderContext(
state = state,
backgroundColor = backgroundColor,
quotesLeft = quotesLeft,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
val paragraphParser = remember { ParagraphParser() }
Column {
paragraphParser.ProcessAllParagraphs(
paragraphs = state.paragraphs,
spaceWidth = spaceWidth,
context = context,
renderSingleParagraph = { paragraph, words, width, ctx ->
paragraphParser.RenderSingleParagraphWithFlowRow(
paragraph = paragraph,
words = words,
spaceWidth = width,
context = ctx,
renderWord = { word, renderContext -> RenderWordWithPreview(word, renderContext) },
)
},
renderImageGallery = { words, ctx -> RenderWordsWithImageGallery(words, ctx) },
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderRegular(
@@ -391,7 +427,7 @@ private fun RenderWordWithoutPreview(
is SecretEmoji -> Text(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -399,33 +435,49 @@ private fun RenderWordWithoutPreview(
}
}
@Composable
private fun RenderWordsWithImageGallery(
words: ImmutableList<Segment>,
context: RenderContext,
) {
val paragraphParser = remember { ParagraphParser() }
paragraphParser.ProcessWordsWithImageGrouping(
words = words,
context = context,
renderSingleWord = { word, ctx -> RenderWordWithPreview(word, ctx) },
renderGallery = { imageContents, accountViewModel ->
ImageGallery(
images = imageContents,
accountViewModel = accountViewModel,
roundedCorner = true,
)
},
)
}
@Composable
private fun RenderWordWithPreview(
word: Segment,
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
quotesLeft: Int,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
context: RenderContext,
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel)
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is ImageSegment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, context.callbackUri, context.accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, context.state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, context.accountViewModel)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, context.accountViewModel)
is CashuSegment -> CashuPreview(word.segmentText, context.accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SecretEmoji -> DisplaySecretEmoji(word, context.state, context.callbackUri, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, accountViewModel, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is BechSegment -> BechLink(word.segmentText, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav)
is HashTagSegment -> HashTag(word, context.nav)
is HashIndexUserSegment -> TagLink(word, context.accountViewModel, context.nav)
is HashIndexEventSegment -> TagLink(word, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is Base64Segment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel)
}
}
@@ -584,39 +636,36 @@ fun CoreSecretMessage(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context =
RenderContext(
state = localSecretContent,
backgroundColor = backgroundColor,
quotesLeft = quotesLeft,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
if (localSecretContent.paragraphs.size == 1) {
localSecretContent.paragraphs[0].words.forEach { word ->
RenderWordWithPreview(
word,
localSecretContent,
backgroundColor,
quotesLeft,
callbackUri,
accountViewModel,
nav,
context,
)
}
} else if (localSecretContent.paragraphs.size > 1) {
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
val paragraphParser = remember { ParagraphParser() }
Column(CashuCardBorders) {
localSecretContent.paragraphs.forEach { paragraph ->
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
localSecretContent,
backgroundColor,
quotesLeft,
callbackUri,
accountViewModel,
nav,
)
}
}
paragraphParser.RenderSingleParagraphWithFlowRow(
paragraph = paragraph,
words = paragraph.words.toImmutableList(),
spaceWidth = spaceWidth,
context = context,
renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) },
)
}
}
}
@@ -625,7 +674,6 @@ fun CoreSecretMessage(
@Composable
fun HashTag(
segment: HashTagSegment,
accountViewModel: AccountViewModel,
nav: INav,
) {
val primary = MaterialTheme.colorScheme.primary
@@ -693,8 +741,8 @@ fun TagLink(
} else {
Row {
DisplayUserFromTag(it, accountViewModel, nav)
word.extras?.let {
Text(text = it)
word.extras?.let { it2 ->
Text(text = it2)
}
}
}
@@ -708,7 +756,7 @@ fun LoadNote(
content: @Composable (Note?) -> Unit,
) {
var note by
remember(baseNoteHex) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(baseNoteHex)) }
remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) }
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.fadeIn
@@ -225,7 +227,7 @@ fun LocalImageView(
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
if (alwayShowImage) true else accountViewModel.settings.showImages(),
)
}
@@ -338,7 +340,7 @@ fun UrlImageView(
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
if (alwayShowImage) true else accountViewModel.settings.showImages(),
)
}
@@ -786,21 +788,26 @@ private suspend fun shareImageFile(
videoUri: String,
mimeType: String?,
) {
// Get sharable URI and file extension
val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri)
try {
// Get sharable URI and file extension
val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri)
// Determine mime type, use provided or derive from extension
val determinedMimeType = mimeType ?: "image/$fileExtension"
// Determine mime type, use provided or derive from extension
val determinedMimeType = mimeType ?: "image/$fileExtension"
// Create share intent
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = determinedMimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Create share intent
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = determinedMimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, null))
context.startActivity(Intent.createChooser(shareIntent, null))
} catch (e: Exception) {
Log.w("ZoomableContentView", "Failed to share image: $videoUri", e)
Toast.makeText(context, context.getString(R.string.unable_to_share_image), Toast.LENGTH_SHORT).show()
}
}
private fun verifyHash(content: MediaUrlContent): Boolean? {
@@ -128,7 +128,7 @@ class MarkdownMediaRenderer(
)
}
} else {
if (!accountViewModel.settings.showUrlPreview.value) {
if (!accountViewModel.settings.showUrlPreview()) {
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
} else {
renderInlineFullWidth(richTextStringBuilder) {
@@ -216,7 +216,7 @@ private fun AccountPicture(
model = profilePicture,
contentDescription = stringRes(R.string.profile_image),
modifier = AccountPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -246,7 +246,7 @@ fun ProfileContentTemplate(
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
.clickable(onClick = onClick),
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
@@ -85,7 +85,7 @@ private fun LoggedInUserPictureDrawer(
contentDescription = stringRes(id = R.string.your_profile_image),
modifier = HeaderPictureModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -603,7 +603,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
contentDescription = stringRes(id = R.string.profile_image),
modifier = MaterialTheme.colorScheme.profile35dpModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -1307,7 +1307,7 @@ private fun ChannelNotePicture(
model = model,
contentDescription = stringRes(R.string.group_picture),
modifier = MaterialTheme.colorScheme.channelNotePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -149,7 +149,7 @@ fun RenderRelay(
RenderRelayIcon(
displayUrl = relayInfo.id ?: relay.url,
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
pingInMs = 0,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
@@ -358,7 +358,7 @@ fun InnerUserPicture(
},
modifier = myImageModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -326,7 +326,7 @@ fun ShortCommunityHeader(
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -376,7 +376,7 @@ fun ShortCommunityHeaderNoActions(
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -88,10 +88,7 @@ private fun WikiNoteHeader(
),
) {
Column {
val automaticallyShowUrlPreview =
remember { accountViewModel.settings.showUrlPreview.value }
if (automaticallyShowUrlPreview) {
if (accountViewModel.settings.showUrlPreview()) {
image?.let {
MyAsyncImage(
imageUrl = it,
@@ -131,4 +131,12 @@ class UiSettingsState(
fun isCompleteUIMode() = uiSettingsFlow.featureSet.value == FeatureSetType.COMPLETE
fun isImmersiveScrollingActive() = uiSettingsFlow.automaticallyHideNavigationBars.value == BooleanType.ALWAYS
fun showProfilePictures() = showProfilePictures.value
fun showUrlPreview() = showUrlPreview.value
fun startVideoPlayback() = startVideoPlayback.value
fun showImages() = showImages.value
}
@@ -158,7 +158,7 @@ fun RenderChannelData(
model = it,
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -253,7 +253,7 @@ fun RenderRelayLinePublicChat(
relay.displayUrl(),
relayInfo.icon,
clickableModifier,
showPicture = accountViewModel.settings.showProfilePictures.value,
showPicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -101,7 +101,7 @@ private fun DrawRelayIcon(
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -87,7 +87,7 @@ fun LongPublicChatChannelHeader(
model = it,
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -70,7 +70,7 @@ fun ShortPublicChatChannelHeader(
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -72,7 +72,7 @@ fun ChannelFileUploadDialog(
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -195,7 +195,7 @@ private fun ChannelRoomCompose(
channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) },
)
@@ -227,7 +227,7 @@ private fun ChannelRoomCompose(
channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) },
)
@@ -197,7 +197,7 @@ fun UrlImageView(
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
if (alwayShowImage) true else accountViewModel.settings.showImages(),
)
}
@@ -267,7 +267,7 @@ fun UrlVideoView(
val automaticallyStartPlayback =
remember(content) {
mutableStateOf<Boolean>(accountViewModel.settings.startVideoPlayback.value)
mutableStateOf<Boolean>(accountViewModel.settings.startVideoPlayback())
}
Box(defaultModifier, contentAlignment = Alignment.Center) {
@@ -201,7 +201,7 @@ private fun RenderBadgeImage(
model = image,
contentDescription = description,
modifier = BadgePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -164,7 +164,7 @@ fun ShowQRDialog(
model = user.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -156,7 +156,7 @@ fun RelayInformationScreen(
RenderRelayIcon(
displayUrl = relay.displayUrl(),
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
RelayStats.get(relay).pingInMs,
iconModifier = LargeRelayIconModifier,
@@ -35,7 +35,7 @@ fun BasicRelaySetupInfoDialog(
) {
BasicRelaySetupInfoClickableRow(
item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onDelete = onDelete,
accountViewModel = accountViewModel,
@@ -292,7 +292,7 @@ private fun DisplaySearchResults(
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -321,7 +321,7 @@ private fun DisplaySearchResults(
channelLastTime = null,
channelLastContent = stringRes(R.string.ephemeral_relay_chat),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -348,7 +348,7 @@ private fun DisplaySearchResults(
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -438,6 +438,7 @@
<string name="no">Ne</string>
<string name="follow_list_selection">Seznam sledovaných</string>
<string name="follow_list_kind3follows">Všechna sledování</string>
<string name="follow_list_kind3follows_users_only">Všech Uživatelů Sledování</string>
<string name="follow_list_kind3follows_proxy">Sleduje přes proxy</string>
<string name="follow_list_aroundme">Kolem mě</string>
<string name="follow_list_global">Globální</string>
@@ -1018,6 +1019,7 @@
<string name="group_relay">Relé chatu</string>
<string name="group_relay_explanation">Relé, ke kterému se všichni uživatelé tohoto chatu připojují</string>
<string name="share_image">Sdílet obrázek…</string>
<string name="unable_to_share_image">Nelze sdílet obrázek, zkuste to prosím později…</string>
<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>
@@ -444,6 +444,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="no">Nein</string>
<string name="follow_list_selection">Folgen-Liste</string>
<string name="follow_list_kind3follows">Alle Folgen</string>
<string name="follow_list_kind3follows_users_only">Alle Benutzer Folgen</string>
<string name="follow_list_kind3follows_proxy">Folgt über Proxy</string>
<string name="follow_list_aroundme">In der Nähe</string>
<string name="follow_list_global">Weltweit</string>
@@ -1023,6 +1024,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="group_relay">Chat-Relais</string>
<string name="group_relay_explanation">Das Relais, mit dem sich alle Benutzer dieses Chats verbinden</string>
<string name="share_image">Bild teilen…</string>
<string name="unable_to_share_image">Bild kann nicht geteilt werden, bitte versuchen Sie es später erneut…</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>
@@ -148,6 +148,7 @@
<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_video">Enregistrer une vidéo</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>
@@ -163,6 +164,8 @@
<string name="blocked_users">Utilisateurs bloqués</string>
<string name="new_threads">Nouveaux sujets</string>
<string name="conversations">Conversations</string>
<string name="feed">Flux</string>
<string name="mod_queue">File d\'attente des mods</string>
<string name="notes">Notes</string>
<string name="replies">Réponses</string>
<string name="mutual">Les vôtres</string>
@@ -437,6 +440,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_users_only">Tous les abonnements de l\'utilisateur</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>
@@ -655,6 +659,8 @@
<string name="messages_new_subject_message">Explication aux membres</string>
<string name="messages_new_subject_message_placeholder">Changement de nom pour les nouveaux objectifs.</string>
<string name="paste_from_clipboard">Coller depuis le presse-papiers</string>
<string name="invalid_nip47_uri_title">URI NIP-47 invalide</string>
<string name="invalid_nip47_uri_description">L\'URI %1$s n\'est pas une URI de connexion NIP-47 valide.</string>
<string name="language_description">Pour l\'interface de l\'App</string>
<string name="theme_description">Sombre, Clair ou thème Système</string>
<string name="automatically_load_images_gifs_description">Charger automatiquement les images et les GIFs</string>
@@ -672,6 +678,9 @@
<string name="media_added_to_profile_gallery">Média ajouté à votre galerie de profil</string>
<string name="created_at">Créé le</string>
<string name="rules">Règles</string>
<string name="community_description">À propos de nous</string>
<string name="guidelines">Lignes directrices</string>
<string name="moderators">Modérateurs</string>
<string name="login_with_external_signer">Se connecter avec Amber</string>
<string name="status_update">Mettez à jour votre statut</string>
<string name="lightning_wallets_not_found">Erreur d\'analyse du message d\'erreur</string>
@@ -884,10 +893,13 @@
<string name="dm_upload">Téléversement de MP</string>
<string name="relay_settings">Paramètres du relais</string>
<string name="public_home_section">Relais publics d\'accueil</string>
<string name="public_home_section_explainer_profile">L\'utilisateur publie son contenu sur ces relais</string>
<string name="public_home_section_explainer">Ce type de relais stocke tout votre contenu. Amethyst enverra vos messages ici et d\'autres utiliseront ces relais pour trouver votre contenu. Insérez entre 1 et 3 relais : il peut s\'agir de relais personnels, de relais payants ou de relais publics.</string>
<string name="public_notif_section">Relais de messagerie publique</string>
<string name="public_notif_section_explainer_profile">L\'utilisateur reçoit les notifications sur ces relais</string>
<string name="public_notif_section_explainer">Ce type de relais reçoit toutes les réponses, commentaires, likes et zaps de vos messages. Insérez de 1 à 3 relais et assurez-vous qu\'ils acceptent les messages de n\'importe qui.</string>
<string name="private_inbox_section">Relais de la boîte de réception MP</string>
<string name="private_inbox_section_explainer_profile">L\'utilisateur reçoit les MPs sur ces relais</string>
<string name="private_inbox_section_explainer">Insérez de 1 à 3 relais pour servir de boîte de réception privée. Les autres utilisateurs utiliseront ces relais pour vous envoyer des MPs. Les relais de la boîte de réception MP doivent accepter tout message de tout le monde, mais ne vous permet que de les télécharger. De bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="private_outbox_section">Relais privés</string>
<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>
@@ -1009,9 +1021,18 @@
<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="unable_to_share_image">Impossible de partager l\'image, veuillez réessayer plus tard…</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>
<string name="open_dropdown_menu">Ouvrir le menu déroulant</string>
<string name="option_of">Option %1$s sur %2$s</string>
<string name="feed_filter_selected">Filtre du flux, %1$s sélectionné</string>
<string name="feed_filter_select_an_option">Filtre du flux, %1$s</string>
<string name="crashreport_found">Rapport d\'erreur trouvé</string>
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Voulez-vous envoyer le rapport d\'erreur récent à Amethyst dans un MP ? Aucune information personnelle ne sera partagée</string>
<string name="crashreport_found_send">Envoyer</string>
<string name="this_message_will_disappear_in_days">Ce message disparaîtra dans %1$d jours</string>
</resources>
@@ -440,6 +440,7 @@
<string name="no">Nem</string>
<string name="follow_list_selection">Követési lista</string>
<string name="follow_list_kind3follows">Követettek bejegyzései</string>
<string name="follow_list_kind3follows_users_only">Összes követett felhasználó</string>
<string name="follow_list_kind3follows_proxy">Követés proxyn keresztül</string>
<string name="follow_list_aroundme">Közelben lévők bejegyzései</string>
<string name="follow_list_global">Globális</string>
@@ -1020,6 +1021,7 @@
<string name="group_relay">Csevegési átjátszó</string>
<string name="group_relay_explanation">Az átjátszó, amelyhez a csevegés összes felhasználója csatlakozik</string>
<string name="share_image">Kép megosztása…</string>
<string name="unable_to_share_image">Nem lehetett megosztani a képet, próbálja meg újra később…</string>
<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>
@@ -437,6 +437,7 @@
<string name="no">Nie</string>
<string name="follow_list_selection">Lista obserwowanych</string>
<string name="follow_list_kind3follows">Obserwowane</string>
<string name="follow_list_kind3follows_users_only">Wszyscy obserwujący</string>
<string name="follow_list_kind3follows_proxy">Obserwuje przez proxy</string>
<string name="follow_list_aroundme">W pobliżu</string>
<string name="follow_list_global">Wszystkie</string>
@@ -438,6 +438,7 @@
<string name="no">Não</string>
<string name="follow_list_selection">Lista de seguidores</string>
<string name="follow_list_kind3follows">Seguindo</string>
<string name="follow_list_kind3follows_users_only">Seguindo do Usuários</string>
<string name="follow_list_kind3follows_proxy">Segue via proxy</string>
<string name="follow_list_aroundme">Perto de mim</string>
<string name="follow_list_global">Global</string>
@@ -1018,6 +1019,7 @@
<string name="group_relay">Relé de chat</string>
<string name="group_relay_explanation">O relé a qual todos os usuários deste chat se conectam</string>
<string name="share_image">Compartilhar imagem…</string>
<string name="unable_to_share_image">Não é possível compartilhar a imagem, por favor tente novamente mais tarde…</string>
<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>
@@ -438,6 +438,7 @@
<string name="no">Nej</string>
<string name="follow_list_selection">Följ lista</string>
<string name="follow_list_kind3follows">Alla följare</string>
<string name="follow_list_kind3follows_users_only">Alla Användare Följare</string>
<string name="follow_list_kind3follows_proxy">Följer via proxy</string>
<string name="follow_list_aroundme">Runt mig</string>
<string name="follow_list_global">Global</string>
@@ -1017,6 +1018,7 @@
<string name="group_relay">Chatt Relä</string>
<string name="group_relay_explanation">Reläet som alla användare av den här chatten ansluter till</string>
<string name="share_image">Dela bild…</string>
<string name="unable_to_share_image">Kunde inte dela bilden, försök igen senare…</string>
<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>
@@ -440,6 +440,7 @@
<string name="no"></string>
<string name="follow_list_selection">关注列表</string>
<string name="follow_list_kind3follows">所有关注</string>
<string name="follow_list_kind3follows_users_only">所有用户关注</string>
<string name="follow_list_kind3follows_proxy">通过代理关注</string>
<string name="follow_list_aroundme">周围的人</string>
<string name="follow_list_global">全球</string>
@@ -1020,6 +1021,7 @@
<string name="group_relay">聊天中继</string>
<string name="group_relay_explanation">此聊天所有用户都连接到的中继</string>
<string name="share_image">分享图片…</string>
<string name="unable_to_share_image">无法分享图片,请稍后重试…</string>
<string name="search_by_hashtag">搜索话题标签:#%1$s</string>
<string name="dont_translate_from">不要翻译</string>
<string name="dont_translate_from_description">此处显示的语言不会被翻译,请选择一种目标语言重新翻译并去除这个提示。</string>
+1
View File
@@ -1240,6 +1240,7 @@
<string name="group_relay">Chat Relay</string>
<string name="group_relay_explanation">The relay that all users of this chat connect to</string>
<string name="share_image">Share image…</string>
<string name="unable_to_share_image">Unable to share image, please try again later…</string>
<string name="search_by_hashtag">Search hashtag: #%1$s</string>
@@ -143,12 +143,14 @@ class Nip44v2Test {
}
@Test
fun invalidMessageLengths() {
fun extendedMessageLengths() {
for (v in vectors.v2?.invalid?.encryptMsgLengths!!) {
val key = RandomInstance.bytes(32)
try {
nip44v2.encrypt("a".repeat(v), key)
fail("Should Throw for $v")
val input = "a".repeat(v)
val result = nip44v2.encrypt(input, key)
val decrypted = nip44v2.decrypt(result, key)
assertEquals(input, decrypted)
} catch (e: Exception) {
assertNotNull(e)
}
@@ -510,6 +510,22 @@
"repeat": 16383,
"plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f",
"payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15"
},
{
"conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0",
"nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0",
"pattern": "!",
"repeat": 65536,
"plaintext_sha256": "b007fe445ff5b583c095c4688c75d8afef66d4c93eb6aeebcea0942e670e1cd3",
"payload_sha256": "f816ffbcb053a0669488992e2d7c57c2d950d3d4af6e19a16297f3e565a4103f"
},
{
"conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0",
"nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0",
"pattern": "a",
"repeat": 20000000,
"plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5",
"payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b"
}
]
},
@@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.utils.LibSodiumInstance
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.CancellationException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Base64
import kotlin.math.floor
import kotlin.math.log2
@@ -42,6 +40,9 @@ class Nip44v2 {
private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b
private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb
private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b
private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded
fun clearCache() {
sharedKeyCache.clearCache()
}
@@ -151,42 +152,49 @@ class Nip44v2 {
check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" }
check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" }
val prefix =
ByteBuffer
.allocate(2)
.order(ByteOrder.BIG_ENDIAN)
.putShort(unpaddedLen.toShort())
.array()
if (unpaddedLen <= maxPlaintextSize) {
// 2 bytes in big endian
intTo2BytesBigEndian(unpaddedLen)
} else if (unpaddedLen <= extMaxPlaintextSize) {
// Extension to allow > 65KB payloads
// 2+4 bytes in big endian
byteArrayOf(0, 0) + intTo4BytesBigEndian(unpaddedLen)
} else {
throw IllegalArgumentException("Message is too long ($unpaddedLen): $plaintext")
}
val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen)
return ByteBuffer.wrap(prefix + unpadded + suffix).array()
return prefix + unpadded + suffix
}
private fun bytesToInt(
byte1: Byte,
byte2: Byte,
bigEndian: Boolean,
): Int =
if (bigEndian) {
(byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF))
} else {
(byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF))
}
fun unpad(padded: ByteArray): String {
val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true)
val unpadded = padded.sliceArray(2 until 2 + unpaddedLen)
val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1])
check(
unpaddedLen in minPlaintextSize..maxPlaintextSize &&
unpadded.size == unpaddedLen &&
padded.size == 2 + calcPaddedLen(unpaddedLen),
) {
"invalid padding ${unpadded.size} != $unpaddedLen"
return if (unpaddedLenPreExt == 0) {
// NIP-44 extension to handle bigger than 65K payloads
val unpaddedLenExt: Int = bytesToIntBigEndian(padded[2], padded[3], padded[4], padded[5])
check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) {
"Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize"
}
check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) {
"Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt"
}
String(padded, 6, unpaddedLenExt)
} else {
check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) {
"Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize"
}
check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) {
"Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt"
}
String(padded, 2, unpaddedLenPreExt)
}
return unpadded.decodeToString()
}
fun hmacAad(
@@ -264,4 +272,41 @@ class Nip44v2 {
byteArrayOf(V.toByte()) + nonce + ciphertext + mac,
)
}
// -----------------------------------
// FASTER METHODS THAN BUFFER WRAPPING
// -----------------------------------
private fun bytesToIntBigEndian(
byte1: Byte,
byte2: Byte,
): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF))
private fun bytesToIntBigEndian(
byte1: Byte,
byte2: Byte,
byte3: Byte,
byte4: Byte,
): Int {
val result =
((byte1.toLong() and 0xFF) shl 24) or
((byte2.toLong() and 0xFF) shl 16) or
((byte3.toLong() and 0xFF) shl 8) or
(byte4.toLong() and 0xFF)
check(result <= Int.MAX_VALUE) {
"JVM cannot handle more than 2GB payloads. Current length: $result"
}
return result.toInt()
}
private fun intTo2BytesBigEndian(value: Int): ByteArray = byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte())
private fun intTo4BytesBigEndian(value: Int): ByteArray =
byteArrayOf(
(value shr 24).toByte(),
(value shr 16).toByte(),
(value shr 8).toByte(),
(value and 0xFF).toByte(),
)
}