Merge branch 'vitorpamplona:main' into profiles-list-management
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-69
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-64
@@ -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) {
|
||||
|
||||
+21
-14
@@ -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? {
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ class MarkdownMediaRenderer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!accountViewModel.settings.showUrlPreview.value) {
|
||||
if (!accountViewModel.settings.showUrlPreview()) {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
} else {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)) },
|
||||
)
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+5
-3
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user