diff --git a/.gitignore b/.gitignore index 3a4da2a3f..b4386f523 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ /.idea/ChatHistory_schema_v2.xml /.idea/artifacts/* /.idea/kotlinNotebook.xml +/.idea/ChatHistory_schema_v3.xml .DS_Store /build /captures diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 567b61ac3..b000d16c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt new file mode 100644 index 000000000..af7fdaa96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -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}") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 791f72ca5..c1c70ca53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 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(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(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}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index b1efde02d..de6d05027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index ea0b3dd99..8caef9721 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -105,7 +105,7 @@ fun VideoView( val automaticallyStartPlayback = remember { mutableStateOf( - if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value, + if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt new file mode 100644 index 000000000..2f355ecf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -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, + 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, + 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, + 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, + 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, + 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, + 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, + 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)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 23b9998c6..a1c1264c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 4e847ec37..4bf3ca5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt new file mode 100644 index 000000000..a943b37af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt @@ -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, + 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, + startIndex: Int, + ): Pair, Int> { + val imageParagraphs = mutableListOf() + 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, + paragraphIndex: Int, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, 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, + context: RenderContext, + renderSingleWord: @Composable (Segment, RenderContext) -> Unit, + renderGallery: @Composable (ImmutableList, 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() + 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, + 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, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, RenderContext) -> Unit, + ) { + var i = 0 + while (i < paragraphs.size) { + i = + processParagraph( + paragraphs = paragraphs, + paragraphIndex = i, + spaceWidth = spaceWidth, + context = context, + renderSingleParagraph = renderSingleParagraph, + renderImageGallery = renderImageGallery, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 720f01c58..8a6d84cac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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, + backgroundColor: MutableState, + 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, + 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, - 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(accountViewModel.getNoteIfExists(baseNoteHex)) } + remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } if (note == null) { LaunchedEffect(key1 = baseNoteHex) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 888e81e29..ac6c65960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 70468d31b..7bb60c2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -128,7 +128,7 @@ class MarkdownMediaRenderer( ) } } else { - if (!accountViewModel.settings.showUrlPreview.value) { + if (!accountViewModel.settings.showUrlPreview()) { renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) } else { renderInlineFullWidth(richTextStringBuilder) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 4bd13e604..cda6c74fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f3baa96fd..f5ef296cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index e7172f76f..bd570bf1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 28844d0dc..ae6e09a15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b2e1a90c2..6e9f9fa58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index fa72f5264..a9d5370f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -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(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 0ee7c5ba5..c5a5b4f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -358,7 +358,7 @@ fun InnerUserPicture( }, modifier = myImageModifier, contentScale = ContentScale.Crop, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index afe51ac8d..b6a857905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index 2a12a8798..e6f53b753 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index 2e9fa918c..9f2302b78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 4f21b9fc8..7c4c24916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 94e645d35..32fcefd1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 23ee37873..e6946e763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index eb3d272b4..22106578a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 0327d45f8..3e7b79bd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 935e2742a..4dca81854 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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)) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 315b86065..575d63492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -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(accountViewModel.settings.startVideoPlayback.value) + mutableStateOf(accountViewModel.settings.startVideoPlayback()) } Box(defaultModifier, contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index b1ed91bd7..74895503b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 76d2d7cf8..ac561432a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index bd112c836..e39803d0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 12bd271a5..6e036b25b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 7bfd3dc09..ab47c8eb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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)) }, ) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 23e828e01..1a5c1026c 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -438,6 +438,7 @@ Ne Seznam sledovaných Všechna sledování + Všech Uživatelů Sledování Sleduje přes proxy Kolem mě Globální @@ -1018,6 +1019,7 @@ Relé chatu Relé, ke kterému se všichni uživatelé tohoto chatu připojují Sdílet obrázek… + Nelze sdílet obrázek, zkuste to prosím později… Vyhledávání hashtag: #%1$s Nepřekládat z Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 77199f2a6..de777d235 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -444,6 +444,7 @@ anz der Bedingungen ist erforderlich Nein Folgen-Liste Alle Folgen + Alle Benutzer Folgen Folgt über Proxy In der Nähe Weltweit @@ -1023,6 +1024,7 @@ anz der Bedingungen ist erforderlich Chat-Relais Das Relais, mit dem sich alle Benutzer dieses Chats verbinden Bild teilen… + Bild kann nicht geteilt werden, bitte versuchen Sie es später erneut… Suche Hashtag: #%1$s Nicht übersetzen von Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen. diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index f4a15147b..c24ae691b 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -148,6 +148,7 @@ Impossible d\'enregistrer la vidéo Charger une image Prendre une photo + Enregistrer une vidéo Enregistrer un message Enregistrer un message Cliquez et maintenez pour enregistrer un message @@ -163,6 +164,8 @@ Utilisateurs bloqués Nouveaux sujets Conversations + Flux + File d\'attente des mods Notes Réponses Les vôtres @@ -437,6 +440,7 @@ Non Liste des Abonnés Tous les abonnements + Tous les abonnements de l\'utilisateur Suivre via Proxy Autour de moi Général @@ -655,6 +659,8 @@ Explication aux membres Changement de nom pour les nouveaux objectifs. Coller depuis le presse-papiers + URI NIP-47 invalide + L\'URI %1$s n\'est pas une URI de connexion NIP-47 valide. Pour l\'interface de l\'App Sombre, Clair ou thème Système Charger automatiquement les images et les GIFs @@ -672,6 +678,9 @@ Média ajouté à votre galerie de profil Créé le Règles + À propos de nous + Lignes directrices + Modérateurs Se connecter avec Amber Mettez à jour votre statut Erreur d\'analyse du message d\'erreur @@ -884,10 +893,13 @@ Téléversement de MP Paramètres du relais Relais publics d\'accueil + L\'utilisateur publie son contenu sur ces relais 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. Relais de messagerie publique + L\'utilisateur reçoit les notifications sur ces relais 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. Relais de la boîte de réception MP + L\'utilisateur reçoit les MPs sur ces relais 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) Relais privés 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. @@ -1009,9 +1021,18 @@ Relais de chat Le relais auquel tous les utilisateurs de ce chat se connectent Partager l\'image… + Impossible de partager l\'image, veuillez réessayer plus tard… Hashtag de recherche : #%1$s Ne pas traduire depuis Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau. Pause Lecture + Ouvrir le menu déroulant + Option %1$s sur %2$s + Filtre du flux, %1$s sélectionné + Filtre du flux, %1$s + Rapport d\'erreur trouvé + Voulez-vous envoyer le rapport d\'erreur récent à Amethyst dans un MP ? Aucune information personnelle ne sera partagée + Envoyer + Ce message disparaîtra dans %1$d jours diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 734fbb971..a242f754a 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -440,6 +440,7 @@ Nem Követési lista Követettek bejegyzései + Összes követett felhasználó Követés proxyn keresztül Közelben lévők bejegyzései Globális @@ -1020,6 +1021,7 @@ Csevegési átjátszó Az átjátszó, amelyhez a csevegés összes felhasználója csatlakozik Kép megosztása… + Nem lehetett megosztani a képet, próbálja meg újra később… Hashtag keresése: #%1$s Innentől NE fordítsa le 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. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index c38a05009..82fe2b56d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -437,6 +437,7 @@ Nie Lista obserwowanych Obserwowane + Wszyscy obserwujący Obserwuje przez proxy W pobliżu Wszystkie diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8425d583c..3cc81998a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -438,6 +438,7 @@ Não Lista de seguidores Seguindo + Seguindo do Usuários Segue via proxy Perto de mim Global @@ -1018,6 +1019,7 @@ Relé de chat O relé a qual todos os usuários deste chat se conectam Compartilhar imagem… + Não é possível compartilhar a imagem, por favor tente novamente mais tarde… Pesquisar hashtag: #%1$s Não Traduzir de Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 3c7ce5d03..3695dd940 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -438,6 +438,7 @@ Nej Följ lista Alla följare + Alla Användare Följare Följer via proxy Runt mig Global @@ -1017,6 +1018,7 @@ Chatt Relä Reläet som alla användare av den här chatten ansluter till Dela bild… + Kunde inte dela bilden, försök igen senare… Sök hashtag: #%1$s Översätt inte från 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. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 9877508b4..cfc05388b 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -440,6 +440,7 @@ 关注列表 所有关注 + 所有用户关注 通过代理关注 周围的人 全球 @@ -1020,6 +1021,7 @@ 聊天中继 此聊天所有用户都连接到的中继 分享图片… + 无法分享图片,请稍后重试… 搜索话题标签:#%1$s 不要翻译 此处显示的语言不会被翻译,请选择一种目标语言重新翻译并去除这个提示。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b8fcf86d4..defa98caf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1240,6 +1240,7 @@ Chat Relay The relay that all users of this chat connect to Share image… + Unable to share image, please try again later… Search hashtag: #%1$s diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index bf87b85cf..0d489fc94 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -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) } diff --git a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json index 8fcabe8a1..4d105420f 100644 --- a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json +++ b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json @@ -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" } ] }, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index e0ca8c2bd..c732abbc0 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -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(), + ) }