Merge pull request #486 from greenart7c3/load_media

Optional media pre-loading
This commit is contained in:
Vitor Pamplona
2023-07-13 13:14:18 -04:00
committed by GitHub
32 changed files with 983 additions and 272 deletions
+3
View File
@@ -173,6 +173,9 @@ dependencies {
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63') implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63') implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
// Local model for language identification // Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4' playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent
@@ -67,6 +68,11 @@ private object PrefKeys {
const val WARN_ABOUT_REPORTS = "warn_about_reports" const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers" const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route" const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
} }
@@ -236,6 +242,47 @@ object LocalPreferences {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
} }
}.apply() }.apply()
val globalPrefs = encryptedPreferences()
globalPrefs.edit().apply {
if (account.settings.automaticallyShowImages == null) {
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
}
if (account.settings.automaticallyStartPlayback == null) {
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
}
if (account.settings.automaticallyShowUrlPreview == null) {
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
fun updateTheme(theme: Int) {
encryptedPreferences().edit().apply {
putInt(PrefKeys.THEME, theme)
}.apply()
}
fun getTheme(): Int {
encryptedPreferences().apply {
return getInt(PrefKeys.THEME, 0)
}
}
fun getPreferredLanguage(): String {
var language = ""
encryptedPreferences().apply {
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
}
return language
} }
fun loadFromEncryptedStorage(): Account? { fun loadFromEncryptedStorage(): Account? {
@@ -340,6 +387,28 @@ object LocalPreferences {
mapOf() mapOf()
} }
val settings = Settings()
encryptedPreferences().apply {
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
} else {
null
}
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
} else {
null
}
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
} else {
null
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
val a = Account( val a = Account(
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels, followingChannels = followingChannels,
@@ -366,7 +435,8 @@ object LocalPreferences {
showSensitiveContent = showSensitiveContent, showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports, warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam, filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute lastReadPerRoute = lastReadPerRoute,
settings = settings
) )
return a return a
@@ -73,7 +73,8 @@ class Account(
var showSensitiveContent: Boolean? = null, var showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true, var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true, var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>() var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var settings: Settings = Settings()
) { ) {
var transientHiddenUsers: Set<String> = setOf() var transientHiddenUsers: Set<String> = setOf()
@@ -84,6 +85,29 @@ class Account(
val saveable: AccountLiveData = AccountLiveData(this) val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null var userProfileCache: User? = null
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: Boolean?
) {
settings.automaticallyStartPlayback = automaticallyStartPlayback
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowUrlPreview(
automaticallyShowUrlPreview: Boolean?
) {
settings.automaticallyShowUrlPreview = automaticallyShowUrlPreview
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: Boolean?
) {
settings.automaticallyShowImages = automaticallyShowImages
live.invalidateData()
saveable.invalidateData()
}
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) { fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnReports warnAboutPostsWithReports = warnReports
@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
@Stable
class Settings(
var automaticallyShowImages: Boolean? = null,
var automaticallyStartPlayback: Boolean? = null,
var preferredLanguage: String? = null,
var automaticallyShowUrlPreview: Boolean? = null
)
@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.service.connectivitystatus
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
object ConnectivityStatus {
private val onMobileData = mutableStateOf(false)
val isOnMobileData: MutableState<Boolean> = onMobileData
private val onWifi = mutableStateOf(false)
val isOnWifi: MutableState<Boolean> = onWifi
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
onMobileData.value = isOnMobileData
onWifi.value = isOnWifi
}
}
@@ -10,15 +10,18 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -32,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.note.Nip47 import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -40,8 +44,7 @@ import kotlinx.coroutines.launch
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -50,16 +53,22 @@ class MainActivity : FragmentActivity() {
val startingPage = uriToRoute(uri) val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs() LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
}
setContent { setContent {
AmethystTheme { val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel { val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity) AccountStateViewModel(this@MainActivity)
} }
AccountScreen(accountStateViewModel, startingPage) AccountScreen(accountStateViewModel, themeViewModel, startingPage)
} }
} }
} }
@@ -130,6 +139,14 @@ class MainActivity : FragmentActivity() {
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities
) { ) {
super.onCapabilitiesChanged(network, networkCapabilities) super.onCapabilitiesChanged(network, networkCapabilities)
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
ConnectivityStatus.updateConnectivityStatus(
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
)
} }
// lost network connection // lost network connection
@@ -6,11 +6,22 @@ import android.os.Build
import android.util.Size import android.util.Size
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -29,8 +40,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -107,7 +117,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scroolState) .verticalScroll(scroolState)
) { ) {
ImageVideoPost(postViewModel, account) ImageVideoPost(postViewModel, accountViewModel)
} }
} }
} }
@@ -123,7 +133,7 @@ fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
} }
@Composable @Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
val fileServers = listOf( val fileServers = listOf(
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), // Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
@@ -180,7 +190,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
} }
} else { } else {
postViewModel.galleryUri?.let { postViewModel.galleryUri?.let {
VideoView(it.toString()) VideoView(it.toString(), accountViewModel = accountViewModel)
} }
} }
} }
@@ -191,7 +201,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.file_server), label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second, placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions, options = fileServerOptions,
explainers = fileServerExplainers, explainers = fileServerExplainers,
onSelect = { onSelect = {
@@ -272,7 +272,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
scope.launch { scope.launch {
postViewModel.imageUploadingError.emit(it) postViewModel.imageUploadingError.emit(it)
} }
} },
accountViewModel = accountViewModel
) )
} }
} }
@@ -330,9 +331,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
) )
) )
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview) VideoView(myUrlPreview, accountViewModel = accountViewModel)
} else { } else {
UrlPreview(myUrlPreview, myUrlPreview) UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
} }
} else if (startsWithNIP19Scheme(myUrlPreview)) { } else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background val bgColor = MaterialTheme.colors.background
@@ -348,7 +349,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
nav nav
) )
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview) UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
} }
} }
} }
@@ -836,7 +837,8 @@ fun ImageVideoDescription(
defaultServer: ServersAvailable, defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable, Boolean) -> Unit, onAdd: (String, ServersAvailable, Boolean) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onError: (String) -> Unit onError: (String) -> Unit,
accountViewModel: AccountViewModel
) { ) {
val resolver = LocalContext.current.contentResolver val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: "" val mediaType = resolver.getType(uri) ?: ""
@@ -966,7 +968,7 @@ fun ImageVideoDescription(
) )
} }
} else { } else {
VideoView(uri.toString()) VideoView(uri.toString(), accountViewModel = accountViewModel)
} }
} }
@@ -238,8 +238,8 @@ private fun RenderWordWithPreview(
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
when (word) { when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state) is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText) is LinkSegment -> UrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText) is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
@@ -256,9 +256,13 @@ private fun RenderWordWithPreview(
} }
@Composable @Composable
private fun ZoomableContentView(word: String, state: RichTextViewerState) { private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel
) {
state.imagesForPager[word]?.let { state.imagesForPager[word]?.let {
ZoomableContentView(it, state.imageList) ZoomableContentView(it, state.imageList, accountViewModel)
} }
} }
@@ -9,37 +9,56 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun UrlPreview(url: String, urlText: String) { fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
var urlPreviewState by remember(url) { val settings = accountViewModel.account.settings
mutableStateOf( val isMobile = ConnectivityStatus.isOnMobileData.value
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
) val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
true -> !isMobile
false -> false
else -> true
} }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). if (!automaticallyShowUrlPreview) {
if (urlPreviewState == UrlPreviewState.Loading) { ClickableUrl(urlText, url)
LaunchedEffect(url) { } else {
launch(Dispatchers.IO) { var urlPreviewState by remember(url) {
UrlCachedPreviewer.previewInfo(url) { mutableStateOf(
launch(Dispatchers.Main) { UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
urlPreviewState = it )
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
launch(Dispatchers.Main) {
urlPreviewState = it
}
} }
} }
} }
} }
}
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state -> Crossfade(
when (state) { targetState = urlPreviewState,
is UrlPreviewState.Loaded -> { animationSpec = tween(durationMillis = 100)
UrlPreviewCard(url, state.previewInfo) ) { state ->
} when (state) {
else -> { is UrlPreviewState.Loaded -> {
ClickableUrl(urlText, url) UrlPreviewCard(url, state.previewInfo, accountViewModel)
}
else -> {
ClickableUrl(urlText, url)
}
} }
} }
} }
@@ -19,66 +19,82 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@Composable @Composable
fun UrlPreviewCard( fun UrlPreviewCard(
url: String, url: String,
previewInfo: UrlInfoItem previewInfo: UrlInfoItem,
accountViewModel: AccountViewModel
) { ) {
val uri = LocalUriHandler.current val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
Row( val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
modifier = Modifier true -> !isMobile
.clickable { runCatching { uri.openUri(url) } } false -> false
.clip(shape = QuoteBorder) else -> true
.border( }
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column {
AsyncImage(
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text( if (!automaticallyShowUrlPreview) {
text = previewInfo.verifiedUrl?.host ?: previewInfo.url, ClickableUrl(url, url)
style = MaterialTheme.typography.caption, } else {
modifier = Modifier val uri = LocalUriHandler.current
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text( Row(
text = previewInfo.title, modifier = Modifier
style = MaterialTheme.typography.body2, .clickable { runCatching { uri.openUri(url) } }
modifier = Modifier .clip(shape = QuoteBorder)
.fillMaxWidth() .border(
.padding(start = 10.dp, end = 10.dp), 1.dp,
maxLines = 1, MaterialTheme.colors.subtleBorder,
overflow = TextOverflow.Ellipsis QuoteBorder
) )
) {
Column {
AsyncImage(
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text( Text(
text = previewInfo.description, text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption, style = MaterialTheme.typography.caption,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), .padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray, color = Color.Gray,
maxLines = 3, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -60,6 +61,8 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DataSource
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -68,7 +71,13 @@ import kotlin.time.measureTimedValue
public var DefaultMutedSetting = mutableStateOf(true) public var DefaultMutedSetting = mutableStateOf(true)
@Composable @Composable
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) { fun LoadThumbAndThenVideoView(
videoUri: String,
description: String? = null,
thumbUri: String,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null
) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) } var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current val context = LocalContext.current
@@ -92,9 +101,9 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) { if (loadingFinished.first) {
if (loadingFinished.second != null) { if (loadingFinished.second != null) {
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog) VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog)
} else { } else {
VideoView(videoUri, description, null, onDialog) VideoView(videoUri, description, null, accountViewModel, onDialog)
} }
} }
} }
@@ -105,10 +114,11 @@ fun VideoView(
videoUri: String, videoUri: String,
description: String? = null, description: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null
) { ) {
val (value, elapsed) = measureTimedValue { val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog) VideoView1(videoUri, description, thumb, onDialog, accountViewModel)
} }
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
} }
@@ -118,7 +128,8 @@ fun VideoView1(
videoUri: String, videoUri: String,
description: String? = null, description: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) { ) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) } var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) } val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
@@ -133,7 +144,7 @@ fun VideoView1(
} }
exoPlayerData?.let { exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog) VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -151,10 +162,11 @@ fun VideoView(
exoPlayerData: VideoPlayer, exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false, defaultToStart: Boolean = false,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) { ) {
val (value, elapsed) = measureTimedValue { val (_, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog) VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel)
} }
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
} }
@@ -166,12 +178,26 @@ fun VideoView1(
exoPlayerData: VideoPlayer, exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false, defaultToStart: Boolean = false,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) { ) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val media = remember { MediaItem.Builder().setUri(videoUri).build() } val media = remember { MediaItem.Builder().setUri(videoUri).build() }
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val automaticallyStartPlayback = remember {
mutableStateOf(
when (settings.automaticallyStartPlayback) {
true -> !isMobile
false -> false
else -> true
}
)
}
exoPlayerData.exoPlayer.apply { exoPlayerData.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
@@ -196,7 +222,7 @@ fun VideoView1(
prepare() prepare()
} }
RenderVideoPlayer(exoPlayerData, thumb, onDialog) RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
DisposableEffect(Unit) { DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -230,6 +256,7 @@ data class VideoThumb(
private fun RenderVideoPlayer( private fun RenderVideoPlayer(
playerData: VideoPlayer, playerData: VideoPlayer,
thumbData: VideoThumb?, thumbData: VideoThumb?,
automaticallyStartPlayback: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)? onDialog: ((Boolean) -> Unit)?
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -241,7 +268,12 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp) .defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center) .align(Alignment.Center)
.onVisibilityChanges { visible -> .onVisibilityChanges { visible ->
if (visible && !playerData.exoPlayer.isPlaying) { if (!automaticallyStartPlayback.value) {
playerData.exoPlayer.stop()
}
if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
} else if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play() playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) { } else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause() playerData.exoPlayer.pause()
@@ -5,7 +5,6 @@ import android.os.Build
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +33,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -70,10 +70,12 @@ import coil.imageLoader
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -167,7 +169,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) { fun ZoomableContentView(
content: ZoomableContent,
images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList(),
accountViewModel: AccountViewModel
) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state // store the dialog open or close state
@@ -194,27 +200,41 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
} }
when (content) { when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier) is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true } is ZoomableUrlVideo -> VideoView(content.url, content.description, accountViewModel = accountViewModel) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier) is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
is ZoomableLocalVideo -> is ZoomableLocalVideo ->
content.localFile?.let { content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true } VideoView(it.toUri().toString(), content.description, accountViewModel = accountViewModel) { dialogOpen = true }
} }
} }
if (dialogOpen) { if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel)
} }
} }
@Composable @Composable
private fun LocalImageView( private fun LocalImageView(
content: ZoomableLocalImage, content: ZoomableLocalImage,
mainImageModifier: Modifier mainImageModifier: Modifier,
accountViewModel: AccountViewModel?
) { ) {
if (content.localFile != null && content.localFile.exists()) { if (content.localFile != null && content.localFile.exists()) {
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val settings = accountViewModel?.account?.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val showImage = remember {
mutableStateOf(
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
)
}
val myModifier = remember { val myModifier = remember {
mainImageModifier mainImageModifier
.widthIn(max = maxWidth) .widthIn(max = maxWidth)
@@ -236,17 +256,19 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null) mutableStateOf<AsyncImagePainter.State?>(null)
} }
AsyncImage( if (showImage.value) {
model = content.localFile, AsyncImage(
contentDescription = content.description, model = content.localFile,
contentScale = contentScale, contentDescription = content.description,
modifier = myModifier, contentScale = contentScale,
onState = { modifier = myModifier,
painterState.value = it onState = {
} painterState.value = it
) }
)
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier) AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier, showImage)
} }
} else { } else {
BlankNote() BlankNote()
@@ -256,9 +278,23 @@ private fun LocalImageView(
@Composable @Composable
private fun UrlImageView( private fun UrlImageView(
content: ZoomableUrlImage, content: ZoomableUrlImage,
mainImageModifier: Modifier mainImageModifier: Modifier,
accountViewModel: AccountViewModel?
) { ) {
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val settings = accountViewModel?.account?.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val showImage = remember {
mutableStateOf(
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
)
}
val myModifier = remember { val myModifier = remember {
mainImageModifier mainImageModifier
.widthIn(max = maxWidth) .widthIn(max = maxWidth)
@@ -280,17 +316,38 @@ private fun UrlImageView(
mutableStateOf<AsyncImagePainter.State?>(null) mutableStateOf<AsyncImagePainter.State?>(null)
} }
AsyncImage( if (showImage.value) {
model = content.url, AsyncImage(
contentDescription = content.description, model = content.url,
contentScale = contentScale, contentDescription = content.description,
modifier = myModifier, contentScale = contentScale,
onState = { modifier = myModifier,
painterState.value = it onState = {
} painterState.value = it
) }
)
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier) AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier, showImage)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
FlowRow() {
ClickableUrl(urlText = url, url = url)
IconButton(
modifier = Modifier.size(20.dp),
onClick = { showImage.value = true }
) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
} }
} }
@@ -301,30 +358,35 @@ private fun AddedImageFeatures(
content: ZoomableLocalImage, content: ZoomableLocalImage,
contentScale: ContentScale, contentScale: ContentScale,
myModifier: Modifier, myModifier: Modifier,
verifiedModifier: Modifier verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) { ) {
when (painter.value) { if (!showImage.value) {
null, is AsyncImagePainter.State.Loading -> { ImageUrlWithDownloadButton(content.uri, showImage)
if (content.blurhash != null) { } else {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) when (painter.value) {
} else { null, is AsyncImagePainter.State.Loading -> {
FlowRow() { if (content.blurhash != null) {
DisplayUrlWithLoadingSymbol(content) DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
} }
} }
}
is AsyncImagePainter.State.Error -> { is AsyncImagePainter.State.Error -> {
BlankNote() BlankNote()
}
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
} }
}
else -> { is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
}
}
else -> {
}
} }
} }
} }
@@ -336,46 +398,51 @@ private fun AddedImageFeatures(
content: ZoomableUrlImage, content: ZoomableUrlImage,
contentScale: ContentScale, contentScale: ContentScale,
myModifier: Modifier, myModifier: Modifier,
verifiedModifier: Modifier verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) { ) {
var verifiedHash by remember { if (!showImage.value) {
mutableStateOf<Boolean?>(null) ImageUrlWithDownloadButton(content.url, showImage)
} } else {
var verifiedHash by remember {
when (painter.value) { mutableStateOf<Boolean?>(null)
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
} }
is AsyncImagePainter.State.Error -> { when (painter.value) {
ClickableUrl(urlText = "${content.url} ", url = content.url) null, is AsyncImagePainter.State.Loading -> {
} if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
is AsyncImagePainter.State.Success -> { } else {
if (content.hash != null) { FlowRow() {
val context = LocalContext.current DisplayUrlWithLoadingSymbol(content)
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
} }
} }
} }
verifiedHash?.let { is AsyncImagePainter.State.Error -> {
HashVerificationSymbol(it, verifiedModifier) ClickableUrl(urlText = "${content.url} ", url = content.url)
} }
}
else -> { is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
val context = LocalContext.current
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
}
verifiedHash?.let {
HashVerificationSymbol(it, verifiedModifier)
}
}
else -> {
}
} }
} }
} }
@@ -473,7 +540,7 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) { fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val view = LocalView.current val view = LocalView.current
DisposableEffect(key1 = Unit) { DisposableEffect(key1 = Unit) {
@@ -515,11 +582,11 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
pagerState = pagerState, pagerState = pagerState,
itemsCount = allImages.size, itemsCount = allImages.size,
itemContent = { index -> itemContent = { index ->
RenderImageOrVideo(allImages[index]) RenderImageOrVideo(allImages[index], accountViewModel)
} }
) )
} else { } else {
RenderImageOrVideo(imageUrl) RenderImageOrVideo(imageUrl, accountViewModel)
} }
Row( Row(
@@ -544,23 +611,23 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
} }
@Composable @Composable
fun RenderImageOrVideo(content: ZoomableContent) { fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: AccountViewModel) {
val mainModifier = Modifier val mainModifier = Modifier
.fillMaxSize() .fillMaxSize()
.zoomable(rememberZoomState()) .zoomable(rememberZoomState())
if (content is ZoomableUrlImage) { if (content is ZoomableUrlImage) {
UrlImageView(content = content, mainImageModifier = mainModifier) UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel)
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description) VideoView(content.url, content.description, accountViewModel = accountViewModel)
} }
} else if (content is ZoomableLocalImage) { } else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier) LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel)
} else if (content is ZoomableLocalVideo) { } else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let { content.localFile?.let {
VideoView(it.toUri().toString(), content.description) VideoView(it.toUri().toString(), content.description, accountViewModel = accountViewModel)
} }
} }
} }
@@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
@@ -34,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -54,6 +56,7 @@ fun AppNavigation(
navController: NavHostController, navController: NavHostController,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
themeViewModel: ThemeViewModel,
nextPage: String? = null nextPage: String? = null
) { ) {
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) } var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
@@ -219,6 +222,15 @@ fun AppNavigation(
) )
}) })
} }
Route.Settings.let { route ->
composable(route.route, route.arguments, content = {
SettingsScreen(
accountViewModel = accountViewModel,
themeViewModel
)
})
}
} }
actionableNextPage?.let { actionableNextPage?.let {
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -299,20 +300,19 @@ fun ListContent(
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return val account = remember(accountState) { accountState?.account } ?: return
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(account.proxy != null) }
var disconnectTorDialog by remember { mutableStateOf(false) }
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
var wantsToEditRelays by remember { var wantsToEditRelays by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var backupDialogOpen by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) }
var disconnectTorDialog by remember { mutableStateOf(false) }
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) }
val context = LocalContext.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
@@ -368,7 +368,6 @@ fun ListContent(
) )
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short) val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
IconRow( IconRow(
title = textTorProxy, title = textTorProxy,
icon = R.drawable.ic_tor, icon = R.drawable.ic_tor,
@@ -391,6 +390,15 @@ fun ListContent(
} }
) )
NavigationRow(
title = stringResource(R.string.settings),
icon = Route.Settings.icon,
tint = MaterialTheme.colors.onBackground,
nav = nav,
scaffoldState = scaffoldState,
route = Route.Settings.route
)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
IconRow( IconRow(
@@ -401,12 +409,12 @@ fun ListContent(
) )
} }
if (backupDialogOpen) { if (wantsToEditRelays) {
AccountBackupDialog(account, onClose = { backupDialogOpen = false }) NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
if (backupDialogOpen) {
AccountBackupDialog(accountViewModel.account, onClose = { backupDialogOpen = false })
} }
val context = LocalContext.current
if (conectOrbotDialogOpen) { if (conectOrbotDialogOpen) {
ConnectOrbotDialog( ConnectOrbotDialog(
onClose = { conectOrbotDialogOpen = false }, onClose = { conectOrbotDialogOpen = false },
@@ -414,7 +422,7 @@ fun ListContent(
conectOrbotDialogOpen = false conectOrbotDialogOpen = false
disconnectTorDialog = false disconnectTorDialog = false
checked = true checked = true
enableTor(account, true, proxyPort, context = context) enableTor(accountViewModel.account, true, proxyPort, context, coroutineScope)
}, },
proxyPort proxyPort
) )
@@ -436,7 +444,7 @@ fun ListContent(
onClick = { onClick = {
disconnectTorDialog = false disconnectTorDialog = false
checked = false checked = false
enableTor(account, false, proxyPort, context) enableTor(accountViewModel.account, false, proxyPort, context, coroutineScope)
} }
) { ) {
Text(text = stringResource(R.string.yes)) Text(text = stringResource(R.string.yes))
@@ -453,23 +461,22 @@ fun ListContent(
} }
) )
} }
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
} }
private fun enableTor( private fun enableTor(
account: Account, account: Account,
checked: Boolean, checked: Boolean,
portNumber: MutableState<String>, portNumber: MutableState<String>,
context: Context context: Context,
scope: CoroutineScope
) { ) {
account.proxyPort = portNumber.value.toInt() account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
LocalPreferences.saveToEncryptedStorage(account) scope.launch(Dispatchers.IO) {
ServiceManager.pause() LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.start(context) ServiceManager.pause()
ServiceManager.start(context)
}
} }
@Composable @Composable
@@ -128,6 +128,11 @@ sealed class Route(
icon = R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
) )
object Settings : Route(
route = "Settings",
icon = R.drawable.ic_settings
)
} }
// ** // **
@@ -355,7 +355,11 @@ private fun RenderNoteRow(
} }
@Composable @Composable
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun RenderLiveActivityThumb(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
val eventUpdates by baseNote.live().metadata.observeAsState() val eventUpdates by baseNote.live().metadata.observeAsState()
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.model.ATag import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
@@ -1303,7 +1304,7 @@ fun RenderAppDefinition(
) )
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }) ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
} }
} else { } else {
Image( Image(
@@ -1354,7 +1355,7 @@ fun RenderAppDefinition(
} }
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }) ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@@ -3086,7 +3087,7 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
Crossfade(targetState = content) { Crossfade(targetState = content) {
if (it != null) { if (it != null) {
SensitivityWarning(note = note, accountViewModel = accountViewModel) { SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(content = it) ZoomableContentView(content = it, accountViewModel = accountViewModel)
} }
} }
} }
@@ -3172,7 +3173,7 @@ private fun RenderNIP95(
Crossfade(targetState = content) { Crossfade(targetState = content) {
if (it != null) { if (it != null) {
SensitivityWarning(note = header, accountViewModel = accountViewModel) { SensitivityWarning(note = header, accountViewModel = accountViewModel) {
ZoomableContentView(content = it) ZoomableContentView(content = it, accountViewModel = accountViewModel)
} }
} }
} }
@@ -3242,12 +3243,14 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
LoadThumbAndThenVideoView( LoadThumbAndThenVideoView(
videoUri = media, videoUri = media,
description = noteEvent.subject(), description = noteEvent.subject(),
thumbUri = cover thumbUri = cover,
accountViewModel = accountViewModel
) )
} }
?: VideoView( ?: VideoView(
videoUri = media, videoUri = media,
noteEvent.subject() description = noteEvent.subject(),
accountViewModel = accountViewModel
) )
} }
} }
@@ -3371,7 +3374,8 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
) { ) {
VideoView( VideoView(
videoUri = media, videoUri = media,
description = subject description = subject,
accountViewModel = accountViewModel
) )
} }
} else { } else {
@@ -3421,17 +3425,27 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
) )
) { ) {
Column { Column {
image?.let { val settings = accountViewModel.account.settings
AsyncImage( val isMobile = ConnectivityStatus.isOnMobileData.value
model = it,
contentDescription = stringResource( val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
R.string.preview_card_image_for, true -> !isMobile
it false -> false
), else -> true
contentScale = ContentScale.FillWidth, }
modifier = Modifier.fillMaxWidth() if (automaticallyShowUrlPreview) {
) image?.let {
} ?: CreateImageHeader(note, accountViewModel) AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: CreateImageHeader(note, accountViewModel)
}
title?.let { title?.let {
Text( Text(
@@ -12,7 +12,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable @Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) { fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) {
val accountState by accountStateViewModel.accountContent.collectAsState() val accountState by accountStateViewModel.accountContent.collectAsState()
Column() { Column() {
@@ -27,7 +27,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
factory = AccountViewModel.Factory(state.account) factory = AccountViewModel.Factory(state.account)
) )
MainScreen(accountViewModel, accountStateViewModel, startingPage) MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
} }
is AccountState.LoggedInViewOnly -> { is AccountState.LoggedInViewOnly -> {
val accountViewModel: AccountViewModel = viewModel( val accountViewModel: AccountViewModel = viewModel(
@@ -35,7 +35,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
factory = AccountViewModel.Factory(state.account) factory = AccountViewModel.Factory(state.account)
) )
MainScreen(accountViewModel, accountStateViewModel, startingPage) MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
} }
} }
} }
@@ -0,0 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class ThemeViewModel : ViewModel() {
private val _theme = MutableLiveData(0)
val theme: LiveData<Int> = _theme
fun onChange(newValue: Int) {
_theme.value = newValue
}
}
@@ -78,19 +78,6 @@ import com.vitorpamplona.amethyst.service.model.RelaySetEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.* import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.DisplayPoW
import com.vitorpamplona.amethyst.ui.note.DisplayReward
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder
@@ -372,7 +359,13 @@ fun NoteMaster(
) { ) {
Column() { Column() {
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && note.channelHex() != null) { if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && note.channelHex() != null) {
ChannelHeader(channelHex = note.channelHex()!!, showVideo = true, showBottomDiviser = false, accountViewModel = accountViewModel, nav = nav) ChannelHeader(
channelHex = note.channelHex()!!,
showVideo = true,
showBottomDiviser = false,
accountViewModel = accountViewModel,
nav = nav
)
} else if (noteEvent is FileHeaderEvent) { } else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote, accountViewModel) FileHeaderDisplay(baseNote, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) { } else if (noteEvent is FileStorageHeaderEvent) {
@@ -40,6 +40,24 @@ class AccountViewModel(val account: Account) : ViewModel() {
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it } val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it } val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it }
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: Boolean?
) {
account.updateAutomaticallyStartPlayback(automaticallyStartPlayback)
}
fun updateAutomaticallyShowUrlPreview(
automaticallyShowUrlPreview: Boolean?
) {
account.updateAutomaticallyShowUrlPreview(automaticallyShowUrlPreview)
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: Boolean?
) {
account.updateAutomaticallyShowImages(automaticallyShowImages)
}
fun isWriteable(): Boolean { fun isWriteable(): Boolean {
return account.isWriteable() return account.isWriteable()
} }
@@ -632,7 +632,8 @@ private fun ShowVideoStreaming(
} }
ZoomableContentView( ZoomableContentView(
content = zoomableUrlVideo content = zoomableUrlVideo,
accountViewModel = accountViewModel
) )
} }
} }
@@ -51,11 +51,17 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { fun MainScreen(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
startingPage: String? = null
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
@@ -211,6 +217,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
userReactionsStatsModel = userReactionsStatsModel, userReactionsStatsModel = userReactionsStatsModel,
navController = navController, navController = navController,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
themeViewModel = themeViewModel,
nextPage = startingPage nextPage = startingPage
) )
} }
@@ -599,7 +599,7 @@ private fun ProfileHeader(
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box { Box {
DrawBanner(baseUser) DrawBanner(baseUser, accountViewModel)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -688,7 +688,7 @@ private fun ProfileHeader(
val profilePic = baseUser.profilePicture() val profilePic = baseUser.profilePicture()
if (zoomImageDialogOpen && profilePic != null) { if (zoomImageDialogOpen && profilePic != null) {
ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }) ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
} }
} }
@@ -1260,7 +1260,7 @@ private fun WatchAndRenderBadgeImage(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
public fun DrawBanner(baseUser: User) { fun DrawBanner(baseUser: User, accountViewModel: AccountViewModel) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val banner = remember(userState) { userState?.user?.info?.banner } val banner = remember(userState) { userState?.user?.info?.banner }
@@ -1284,7 +1284,7 @@ public fun DrawBanner(baseUser: User) {
) )
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }) ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
} }
} else { } else {
Image( Image(
@@ -0,0 +1,314 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.ExposedDropdownMenuDefaults
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
fun Context.getLocaleListFromXml(): LocaleListCompat {
val tagsList = mutableListOf<CharSequence>()
try {
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
tagsList.add(xpp.getAttributeValue(0))
}
}
xpp.next()
}
} catch (e: XmlPullParserException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
return LocaleListCompat.forLanguageTags(tagsList.joinToString(","))
}
fun Context.getLangPreferenceDropdownEntries(): Map<String, String> {
val localeList = getLocaleListFromXml()
val map = mutableMapOf<String, String>()
for (a in 0 until localeList.size()) {
localeList[a].let {
map.put(it!!.getDisplayName(it).replaceFirstChar { char -> char.uppercase() }, it.toLanguageTag())
}
}
return map
}
fun getLanguageIndex(languageEntries: Map<String, String>): Int {
var languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag())
if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language)
if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf("en")
return languageIndex
}
@Composable
fun SettingsScreen(
accountViewModel: AccountViewModel,
themeViewModel: ThemeViewModel
) {
val scope = rememberCoroutineScope()
val selectedItens = persistentListOf(
stringResource(R.string.always),
stringResource(R.string.wifi_only),
stringResource(R.string.never).replaceFirstChar {
it.uppercase()
}
)
val settings = accountViewModel.account.settings
val index = if (settings.automaticallyShowImages == null) { 0 } else {
if (settings.automaticallyShowImages == true) 1 else 2
}
val videoIndex = if (settings.automaticallyStartPlayback == null) { 0 } else {
if (settings.automaticallyShowImages == true) 1 else 2
}
val linkIndex = if (settings.automaticallyShowUrlPreview == null) { 0 } else {
if (settings.automaticallyShowUrlPreview == true) 1 else 2
}
val themeItens = persistentListOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark)
)
val themeIndex = themeViewModel.theme.value ?: 0
val context = LocalContext.current
val languageEntries = context.getLangPreferenceDropdownEntries()
val languageList = languageEntries.keys.toImmutableList()
val languageIndex = getLanguageIndex(languageEntries)
Column(
StdPadding
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Section(stringResource(R.string.application_preferences))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(R.string.language),
placeholder = languageList[languageIndex],
options = languageList,
onSelect = {
scope.launch(Dispatchers.IO) {
val locale = languageEntries[languageList[it]]
accountViewModel.account.settings.preferredLanguage = locale
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it]])
AppCompatDelegate.setApplicationLocales(appLocale)
}
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(R.string.theme),
placeholder = themeItens[themeIndex],
options = themeItens,
onSelect = {
themeViewModel.onChange(it)
scope.launch(Dispatchers.IO) {
LocalPreferences.updateTheme(it)
}
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(R.string.automatically_load_images_gifs),
placeholder = selectedItens[index],
options = selectedItens,
onSelect = {
val automaticallyShowImages = when (it) {
1 -> true
2 -> false
else -> null
}
scope.launch(Dispatchers.IO) {
accountViewModel.updateAutomaticallyShowImages(automaticallyShowImages)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(R.string.automatically_play_videos),
placeholder = selectedItens[videoIndex],
options = selectedItens,
onSelect = {
val automaticallyStartPlayback = when (it) {
1 -> true
2 -> false
else -> null
}
scope.launch(Dispatchers.IO) {
accountViewModel.updateAutomaticallyStartPlayback(automaticallyStartPlayback)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(R.string.automatically_show_url_preview),
placeholder = selectedItens[linkIndex],
options = selectedItens,
onSelect = {
val automaticallyShowUrlPreview = when (it) {
1 -> true
2 -> false
else -> null
}
scope.launch(Dispatchers.IO) {
accountViewModel.updateAutomaticallyShowUrlPreview(automaticallyShowUrlPreview)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun DropDownSettings(
selectedItem: MutableState<String>,
listItems: Array<String>,
title: String
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
modifier = Modifier.padding(8.dp),
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = selectedItem.value,
onValueChange = {},
readOnly = true,
label = { Text(text = title) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
listItems.forEach { selectedOption ->
DropdownMenuItem(
onClick = {
selectedItem.value = selectedOption
expanded = false
}
) {
Text(text = selectedOption)
}
}
}
}
}
@Composable
fun Section(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Spacer(modifier = DoubleVertSpacer)
}
@@ -14,6 +14,7 @@ import androidx.compose.material.darkColors
import androidx.compose.material.lightColors import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
private val DarkColorPalette = darkColors( private val DarkColorPalette = darkColors(
primary = Purple200, primary = Purple200,
@@ -304,12 +306,14 @@ val Colors.innerPostModifier: Modifier
get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier
@Composable @Composable
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { fun AmethystTheme(themeViewModel: ThemeViewModel, content: @Composable () -> Unit) {
val colors = if (darkTheme) { val theme = themeViewModel.theme.observeAsState()
DarkColorPalette val darkTheme = when (theme.value) {
} else { 2 -> true
LightColorPalette 1 -> false
else -> isSystemInDarkTheme()
} }
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
MaterialTheme( MaterialTheme(
colors = colors, colors = colors,
@@ -319,10 +323,14 @@ fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composab
) )
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode && darkTheme) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colors.background.toArgb() if (darkTheme) {
window.statusBarColor = colors.background.toArgb()
} else {
window.statusBarColor = colors.primary.toArgb()
}
} }
} }
} }
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M388,880L368,754Q349,747 328,735Q307,723 291,710L173,764L80,600L188,521Q186,512 185.5,500.5Q185,489 185,480Q185,471 185.5,459.5Q186,448 188,439L80,360L173,196L291,250Q307,237 328,225Q349,213 368,207L388,80L572,80L592,206Q611,213 632.5,224.5Q654,236 669,250L787,196L880,360L772,437Q774,447 774.5,458.5Q775,470 775,480Q775,490 774.5,501Q774,512 772,522L880,600L787,764L669,710Q653,723 632.5,735.5Q612,748 592,754L572,880L388,880ZM480,610Q534,610 572,572Q610,534 610,480Q610,426 572,388Q534,350 480,350Q426,350 388,388Q350,426 350,480Q350,534 388,572Q426,610 480,610Z"/>
</vector>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.Amethyst" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item> <item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowBackground">@color/black</item> <item name="android:windowBackground">@color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\ <item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\
+13
View File
@@ -484,4 +484,17 @@
<string name="add_sensitive_content_label">Sensitive Content</string> <string name="add_sensitive_content_label">Sensitive Content</string>
<string name="add_sensitive_content_description">Adds sensitive content warning before showing this content</string> <string name="add_sensitive_content_description">Adds sensitive content warning before showing this content</string>
<string name="settings">Settings</string>
<string name="always">Always</string>
<string name="wifi_only">Wifi-only</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="application_preferences">Application preferences</string>
<string name="language">Language</string>
<string name="theme">Theme</string>
<string name="automatically_load_images_gifs">Automatically load images/gifs</string>
<string name="automatically_play_videos">Automatically play videos</string>
<string name="automatically_show_url_preview">Automatically show url preview</string>
<string name="load_image">Load Image</string>
</resources> </resources>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.Amethyst" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item> <item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
</style> </style>
+4 -5
View File
@@ -7,17 +7,16 @@
<locale android:name="fa"/> <locale android:name="fa"/>
<locale android:name="fr"/> <locale android:name="fr"/>
<locale android:name="hu"/> <locale android:name="hu"/>
<locale android:name="night"/>
<locale android:name="nl"/> <locale android:name="nl"/>
<locale android:name="pr-rBR"/> <locale android:name="pt-BR"/>
<locale android:name="ru"/> <locale android:name="ru"/>
<locale android:name="sv-rSE"/> <locale android:name="sv-SE"/>
<locale android:name="ta"/> <locale android:name="ta"/>
<locale android:name="tr"/> <locale android:name="tr"/>
<locale android:name="uk"/> <locale android:name="uk"/>
<locale android:name="zh"/> <locale android:name="zh"/>
<locale android:name="zh-rHK"/> <locale android:name="zh-HK"/>
<locale android:name="zh-rTW"/> <locale android:name="zh-TW"/>
<locale android:name="en"/> <locale android:name="en"/>
<locale android:name="en-GB"/> <locale android:name="en-GB"/>
<locale android:name="ja"/> <locale android:name="ja"/>