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-commonmark:a0954aba63')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
// Local model for language identification
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.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
@@ -67,6 +68,11 @@ private object PrefKeys {
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
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" }
}
@@ -236,6 +242,47 @@ object LocalPreferences {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.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? {
@@ -340,6 +387,28 @@ object LocalPreferences {
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(
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
@@ -366,7 +435,8 @@ object LocalPreferences {
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute
lastReadPerRoute = lastReadPerRoute,
settings = settings
)
return a
@@ -73,7 +73,8 @@ class Account(
var showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: 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()
@@ -84,6 +85,29 @@ class Account(
val saveable: AccountLiveData = AccountLiveData(this)
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) {
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 androidx.activity.compose.setContent
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.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
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.ChannelMessageEvent
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.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -40,8 +44,7 @@ import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -50,16 +53,22 @@ class MainActivity : FragmentActivity() {
val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
}
setContent {
AmethystTheme {
val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, startingPage)
AccountScreen(accountStateViewModel, themeViewModel, startingPage)
}
}
}
@@ -130,6 +139,14 @@ class MainActivity : FragmentActivity() {
networkCapabilities: 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
@@ -6,11 +6,22 @@ import android.os.Build
import android.util.Size
import android.widget.Toast
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.text.KeyboardOptions
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.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -29,8 +40,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -107,7 +117,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel, account)
ImageVideoPost(postViewModel, accountViewModel)
}
}
}
@@ -123,7 +133,7 @@ fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
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.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 {
postViewModel.galleryUri?.let {
VideoView(it.toString())
VideoView(it.toString(), accountViewModel = accountViewModel)
}
}
}
@@ -191,7 +201,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
) {
TextSpinner(
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,
explainers = fileServerExplainers,
onSelect = {
@@ -272,7 +272,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
scope.launch {
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) }) {
VideoView(myUrlPreview)
VideoView(myUrlPreview, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
@@ -348,7 +349,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
@@ -836,7 +837,8 @@ fun ImageVideoDescription(
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable, Boolean) -> Unit,
onCancel: () -> Unit,
onError: (String) -> Unit
onError: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
@@ -966,7 +968,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri.toString())
VideoView(uri.toString(), accountViewModel = accountViewModel)
}
}
@@ -238,8 +238,8 @@ private fun RenderWordWithPreview(
nav: (String) -> Unit
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText)
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
@@ -256,9 +256,13 @@ private fun RenderWordWithPreview(
}
@Composable
private fun ZoomableContentView(word: String, state: RichTextViewerState) {
private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel
) {
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.setValue
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.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
var urlPreviewState by remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
)
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
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 (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
launch(Dispatchers.Main) {
urlPreviewState = it
if (!automaticallyShowUrlPreview) {
ClickableUrl(urlText, url)
} else {
var urlPreviewState by remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
)
}
// 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 ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
ClickableUrl(urlText, url)
Crossfade(
targetState = urlPreviewState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
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 coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
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.subtleBorder
@Composable
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem
previewInfo: UrlInfoItem,
accountViewModel: AccountViewModel
) {
val uri = LocalUriHandler.current
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = QuoteBorder)
.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()
)
val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
true -> !isMobile
false -> false
else -> true
}
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!automaticallyShowUrlPreview) {
ClickableUrl(url, url)
} else {
val uri = LocalUriHandler.current
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = QuoteBorder)
.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(
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
)
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
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.vitorpamplona.amethyst.VideoCache
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.launch
import kotlin.time.ExperimentalTime
@@ -68,7 +71,13 @@ import kotlin.time.measureTimedValue
public var DefaultMutedSetting = mutableStateOf(true)
@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)) }
val context = LocalContext.current
@@ -92,9 +101,9 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog)
} else {
VideoView(videoUri, description, null, onDialog)
VideoView(videoUri, description, null, accountViewModel, onDialog)
}
}
}
@@ -105,10 +114,11 @@ fun VideoView(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog)
VideoView1(videoUri, description, thumb, onDialog, accountViewModel)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -118,7 +128,8 @@ fun VideoView1(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
@@ -133,7 +144,7 @@ fun VideoView1(
}
exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel)
}
DisposableEffect(Unit) {
@@ -151,10 +162,11 @@ fun VideoView(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog)
val (_, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -166,12 +178,26 @@ fun VideoView1(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
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 {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
@@ -196,7 +222,7 @@ fun VideoView1(
prepare()
}
RenderVideoPlayer(exoPlayerData, thumb, onDialog)
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
@@ -230,6 +256,7 @@ data class VideoThumb(
private fun RenderVideoPlayer(
playerData: VideoPlayer,
thumbData: VideoThumb?,
automaticallyStartPlayback: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
@@ -241,7 +268,12 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.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()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
@@ -5,7 +5,6 @@ import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +33,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -70,10 +70,12 @@ import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
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.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
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.imageModifier
import kotlinx.collections.immutable.ImmutableList
@@ -167,7 +169,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable
@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
// store the dialog open or close state
@@ -194,27 +200,41 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
}
when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
is ZoomableUrlVideo -> VideoView(content.url, content.description, accountViewModel = accountViewModel) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
VideoView(it.toUri().toString(), content.description, accountViewModel = accountViewModel) { dialogOpen = true }
}
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel)
}
}
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
mainImageModifier: Modifier
mainImageModifier: Modifier,
accountViewModel: AccountViewModel?
) {
if (content.localFile != null && content.localFile.exists()) {
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 {
mainImageModifier
.widthIn(max = maxWidth)
@@ -236,17 +256,19 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
if (showImage.value) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier, showImage)
}
} else {
BlankNote()
@@ -256,9 +278,23 @@ private fun LocalImageView(
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
mainImageModifier: Modifier,
accountViewModel: AccountViewModel?
) {
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 {
mainImageModifier
.widthIn(max = maxWidth)
@@ -280,17 +316,38 @@ private fun UrlImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
if (showImage.value) {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
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,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
if (!showImage.value) {
ImageUrlWithDownloadButton(content.uri, showImage)
} else {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
}
is AsyncImagePainter.State.Error -> {
BlankNote()
}
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
is AsyncImagePainter.State.Error -> {
BlankNote()
}
}
else -> {
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
}
}
else -> {
}
}
}
}
@@ -336,46 +398,51 @@ private fun AddedImageFeatures(
content: ZoomableUrlImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
if (!showImage.value) {
ImageUrlWithDownloadButton(content.url, showImage)
} else {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
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
}
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
verifiedHash?.let {
HashVerificationSymbol(it, verifiedModifier)
is AsyncImagePainter.State.Error -> {
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)
@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
DisposableEffect(key1 = Unit) {
@@ -515,11 +582,11 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
RenderImageOrVideo(allImages[index], accountViewModel)
}
)
} else {
RenderImageOrVideo(imageUrl)
RenderImageOrVideo(imageUrl, accountViewModel)
}
Row(
@@ -544,23 +611,23 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
@Composable
fun RenderImageOrVideo(content: ZoomableContent) {
fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: AccountViewModel) {
val mainModifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
if (content is ZoomableUrlImage) {
UrlImageView(content = content, mainImageModifier = mainModifier)
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel)
} else if (content is ZoomableUrlVideo) {
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) {
LocalImageView(content = content, mainImageModifier = mainModifier)
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
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.NostrVideoFeedViewModel
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.BookmarkListScreen
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.ProfileScreen
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.VideoScreen
import kotlinx.coroutines.delay
@@ -54,6 +56,7 @@ fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
themeViewModel: ThemeViewModel,
nextPage: String? = null
) {
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 {
@@ -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.Size16dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -299,20 +300,19 @@ fun ListContent(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
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() }
var wantsToEditRelays by remember {
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(
modifier = modifier
.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)
IconRow(
title = textTorProxy,
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))
IconRow(
@@ -401,12 +409,12 @@ fun ListContent(
)
}
if (backupDialogOpen) {
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
if (backupDialogOpen) {
AccountBackupDialog(accountViewModel.account, onClose = { backupDialogOpen = false })
}
val context = LocalContext.current
if (conectOrbotDialogOpen) {
ConnectOrbotDialog(
onClose = { conectOrbotDialogOpen = false },
@@ -414,7 +422,7 @@ fun ListContent(
conectOrbotDialogOpen = false
disconnectTorDialog = false
checked = true
enableTor(account, true, proxyPort, context = context)
enableTor(accountViewModel.account, true, proxyPort, context, coroutineScope)
},
proxyPort
)
@@ -436,7 +444,7 @@ fun ListContent(
onClick = {
disconnectTorDialog = false
checked = false
enableTor(account, false, proxyPort, context)
enableTor(accountViewModel.account, false, proxyPort, context, coroutineScope)
}
) {
Text(text = stringResource(R.string.yes))
@@ -453,23 +461,22 @@ fun ListContent(
}
)
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
}
private fun enableTor(
account: Account,
checked: Boolean,
portNumber: MutableState<String>,
context: Context
context: Context,
scope: CoroutineScope
) {
account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.pause()
ServiceManager.start(context)
scope.launch(Dispatchers.IO) {
LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.pause()
ServiceManager.start(context)
}
}
@Composable
@@ -128,6 +128,11 @@ sealed class Route(
icon = R.drawable.ic_moments,
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
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 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.UserMetadata
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.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
@@ -1303,7 +1304,7 @@ fun RenderAppDefinition(
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
}
} else {
Image(
@@ -1354,7 +1355,7 @@ fun RenderAppDefinition(
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
}
Spacer(Modifier.weight(1f))
@@ -3086,7 +3087,7 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(content = it)
ZoomableContentView(content = it, accountViewModel = accountViewModel)
}
}
}
@@ -3172,7 +3173,7 @@ private fun RenderNIP95(
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
ZoomableContentView(content = it)
ZoomableContentView(content = it, accountViewModel = accountViewModel)
}
}
}
@@ -3242,12 +3243,14 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
LoadThumbAndThenVideoView(
videoUri = media,
description = noteEvent.subject(),
thumbUri = cover
thumbUri = cover,
accountViewModel = accountViewModel
)
}
?: VideoView(
videoUri = media,
noteEvent.subject()
description = noteEvent.subject(),
accountViewModel = accountViewModel
)
}
}
@@ -3371,7 +3374,8 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
) {
VideoView(
videoUri = media,
description = subject
description = subject,
accountViewModel = accountViewModel
)
}
} else {
@@ -3421,17 +3425,27 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
)
) {
Column {
image?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: CreateImageHeader(note, accountViewModel)
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
true -> !isMobile
false -> false
else -> true
}
if (automaticallyShowUrlPreview) {
image?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: CreateImageHeader(note, accountViewModel)
}
title?.let {
Text(
@@ -12,7 +12,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {
fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) {
val accountState by accountStateViewModel.accountContent.collectAsState()
Column() {
@@ -27,7 +27,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
factory = AccountViewModel.Factory(state.account)
)
MainScreen(accountViewModel, accountStateViewModel, startingPage)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
}
is AccountState.LoggedInViewOnly -> {
val accountViewModel: AccountViewModel = viewModel(
@@ -35,7 +35,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
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.ui.components.ObserveDisplayNip05Status
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.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
@@ -372,7 +359,13 @@ fun NoteMaster(
) {
Column() {
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) {
FileHeaderDisplay(baseNote, accountViewModel)
} 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 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 {
return account.isWriteable()
}
@@ -632,7 +632,8 @@ private fun ShowVideoStreaming(
}
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.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@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 navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
@@ -211,6 +217,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
userReactionsStatsModel = userReactionsStatsModel,
navController = navController,
accountViewModel = accountViewModel,
themeViewModel = themeViewModel,
nextPage = startingPage
)
}
@@ -599,7 +599,7 @@ private fun ProfileHeader(
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box {
DrawBanner(baseUser)
DrawBanner(baseUser, accountViewModel)
Box(
modifier = Modifier
@@ -688,7 +688,7 @@ private fun ProfileHeader(
val profilePic = baseUser.profilePicture()
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)
@Composable
public fun DrawBanner(baseUser: User) {
fun DrawBanner(baseUser: User, accountViewModel: AccountViewModel) {
val userState by baseUser.live().metadata.observeAsState()
val banner = remember(userState) { userState?.user?.info?.banner }
@@ -1284,7 +1284,7 @@ public fun DrawBanner(baseUser: User) {
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
}
} else {
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.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
private val DarkColorPalette = darkColors(
primary = Purple200,
@@ -304,12 +306,14 @@ val Colors.innerPostModifier: Modifier
get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier
@Composable
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
fun AmethystTheme(themeViewModel: ThemeViewModel, content: @Composable () -> Unit) {
val theme = themeViewModel.theme.observeAsState()
val darkTheme = when (theme.value) {
2 -> true
1 -> false
else -> isSystemInDarkTheme()
}
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
MaterialTheme(
colors = colors,
@@ -319,10 +323,14 @@ fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composab
)
val view = LocalView.current
if (!view.isInEditMode && darkTheme) {
if (!view.isInEditMode) {
SideEffect {
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"?>
<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:windowBackground">@color/black</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_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>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
</style>
+4 -5
View File
@@ -7,17 +7,16 @@
<locale android:name="fa"/>
<locale android:name="fr"/>
<locale android:name="hu"/>
<locale android:name="night"/>
<locale android:name="nl"/>
<locale android:name="pr-rBR"/>
<locale android:name="pt-BR"/>
<locale android:name="ru"/>
<locale android:name="sv-rSE"/>
<locale android:name="sv-SE"/>
<locale android:name="ta"/>
<locale android:name="tr"/>
<locale android:name="uk"/>
<locale android:name="zh"/>
<locale android:name="zh-rHK"/>
<locale android:name="zh-rTW"/>
<locale android:name="zh-HK"/>
<locale android:name="zh-TW"/>
<locale android:name="en"/>
<locale android:name="en-GB"/>
<locale android:name="ja"/>