Compare commits

..
55 Commits
Author SHA1 Message Date
Vitor Pamplona 2e7a955ada v0.68.2 2023-07-13 19:30:52 -04:00
Vitor Pamplona 4aa961437b formatting 2023-07-13 19:23:37 -04:00
Vitor Pamplona 0d9399a1a4 Fixing Right To Left Text 2023-07-13 19:23:26 -04:00
Vitor PamplonaandGitHub 9b62cf563a Merge pull request #502 from greenart7c3/main
Aways show images/videos in the dialog screen
2023-07-13 18:04:20 -04:00
greenart7c3 7c525e2f2f show videos as links 2023-07-13 18:07:57 -03:00
greenart7c3 63888d55fd fix image loading in ZoomableImageDialog 2023-07-13 17:57:54 -03:00
Vitor Pamplona 7287ead851 v0.68.1 2023-07-13 13:17:20 -04:00
Vitor PamplonaandGitHub 6fc829f4f1 Merge pull request #486 from greenart7c3/load_media
Optional media pre-loading
2023-07-13 13:14:18 -04:00
Vitor PamplonaandGitHub d12b0ce9be Merge pull request #500 from akiomik/fix-string-resource-mapping
Fix string resource mapping when like clicked
2023-07-13 12:56:00 -04:00
Vitor PamplonaandGitHub 393714eb42 Merge pull request #499 from akiomik/extract-block-report-resource
Extract string resource for Block / Report
2023-07-13 12:55:33 -04:00
Vitor Pamplona fe363b8a2d Updates readme 2023-07-13 11:27:52 -04:00
Vitor Pamplona 064ce7e6e6 v0.68.0 2023-07-13 11:26:35 -04:00
Vitor Pamplona af72846998 Support for Classifieds 2023-07-13 11:20:03 -04:00
Vitor Pamplona 899b4f7c90 Quick fix for multiple emoji's in one line 2023-07-13 11:19:07 -04:00
Akiomi Kamakura 2e5b41b711 Add ja to locales_config.xml 2023-07-13 22:57:59 +09:00
Vitor Pamplona 809f048b44 Merge remote-tracking branch 'origin/HEAD' 2023-07-13 08:52:58 -04:00
Vitor Pamplona 51c0c44a2e Formatting 2023-07-13 08:52:47 -04:00
Vitor Pamplona 811d6d8c67 Removes emoji package list selection from Profile threads. 2023-07-13 08:51:54 -04:00
Akiomi Kamakura c1b9bf42ad Fix string resource mapping when like clicked 2023-07-13 11:05:17 +09:00
Akiomi Kamakura cafe742cd8 Extract string resource for Block / Report 2023-07-13 11:00:15 +09:00
Vitor PamplonaandGitHub ba50ec052b Merge pull request #498 from akiomik/i18n-ja
Add i18n support for ja
2023-07-12 19:59:31 -04:00
Vitor Pamplona 386f97abf8 Updates readme 2023-07-12 19:58:57 -04:00
7678b1b100 Add i18n support for ja
Co-authored-by: h3y6e <h3y6e@pm.me>
Co-authored-by: ys3669 <ys3669jp@gmail.com>
2023-07-13 07:51:49 +09:00
Vitor Pamplona 9df82a78e7 v0.67.1 2023-07-12 18:19:47 -04:00
Vitor Pamplona c87466ada4 when clicking in a repost of a note, redirects the user to the note directly. 2023-07-12 18:19:40 -04:00
Vitor Pamplona 135eb6763a Adjustments to allow removing of the selected emoji packs from the reaction setup 2023-07-12 18:19:20 -04:00
greenart7c3 3254479ee5 add settings icon 2023-07-12 11:41:37 -03:00
greenart7c3 6cc4266435 appcompat needed for language picker and theme 2023-07-12 11:31:13 -03:00
greenart7c3 3e48b293b0 create ThemeViewModel using viewModel contructor 2023-07-12 11:31:13 -03:00
greenart7c3 492b9494ec fix theme not changing after changing language 2023-07-12 11:31:12 -03:00
greenart7c3 b31d2d49d6 do not load preview of long form text if automaticallyShowUrlPreview is false 2023-07-12 11:31:12 -03:00
greenart7c3 245bd4a44d fix lint 2023-07-12 11:31:12 -03:00
greenart7c3 6d4a887d00 use accountviewmodel as parameter instead of MutableState 2023-07-12 11:31:12 -03:00
greenart7c3 f95023f59d replace imageBox with url link with download button 2023-07-12 11:31:12 -03:00
greenart7c3 e69c1cf3a9 remove unused showImage parameters 2023-07-12 11:31:12 -03:00
greenart7c3 b5229f5fe1 only load one image when theres multiple images in the note 2023-07-12 11:31:12 -03:00
greenart7c3 e2ef7cfd70 add string resources 2023-07-12 11:31:12 -03:00
greenart7c3 6e89e69fb6 refactor settings to use TextSpinner and save settings when selecting an option 2023-07-12 11:31:11 -03:00
greenart7c3 1e6ee67932 move back settings to drawer 2023-07-12 11:31:11 -03:00
greenart7c3 0fead83279 fix load image box size 2023-07-12 11:31:11 -03:00
greenart7c3 66952c9212 add url preview settings 2023-07-12 11:31:11 -03:00
greenart7c3 38bb9f457d fix statusbar color when changing theme 2023-07-12 11:31:11 -03:00
greenart7c3 3e615bb152 check for automaticallyStartPlayback setting 2023-07-12 11:31:11 -03:00
greenart7c3 f83a19260f fix build after rebase 2023-07-12 11:31:11 -03:00
greenart7c3 69c3c41196 move drawer options to settings screen 2023-07-12 11:31:11 -03:00
greenart7c3 6c1a4f3971 replace load image button with a rectangle box 2023-07-12 11:31:10 -03:00
greenart7c3 035ba899ee add language picker 2023-07-12 11:31:10 -03:00
greenart7c3 33cc261b62 add settings to change theme 2023-07-12 11:31:10 -03:00
greenart7c3 fee6f19d5e check for load images option 2023-07-12 11:31:10 -03:00
greenart7c3 68e8a13f73 add options to disable auto playback e show images in the settings screen 2023-07-12 11:31:10 -03:00
greenart7c3 a639c3f36d add media settings 2023-07-12 11:31:10 -03:00
greenart7c3 a5f10fef60 created settings screen 2023-07-12 11:31:10 -03:00
greenart7c3 d334c04c47 do not autoplay videos when on mobile data 2023-07-12 11:31:09 -03:00
greenart7c3 7c3d9aad1d add ConnectivityStatus singleton 2023-07-12 11:31:09 -03:00
greenart7c3 ceb81aa2bf created button to load images] 2023-07-12 11:31:09 -03:00
48 changed files with 2020 additions and 380 deletions
+4 -1
View File
@@ -63,7 +63,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
@@ -76,6 +76,9 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
+6 -3
View File
@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 246
versionName "0.67.0"
versionCode 250
versionName "0.68.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
}
buildTypes {
@@ -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
@@ -402,6 +402,25 @@ object LocalCache {
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -1306,6 +1325,7 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ClassifiedsEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
@@ -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
)
@@ -30,9 +30,11 @@ data class RichTextViewerState(
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ImmutableList<Segment>>
val paragraphs: ImmutableList<ParagraphState>
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
@@ -108,13 +110,15 @@ class RichTextParser() {
)
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
@@ -125,13 +129,9 @@ class RichTextParser() {
}
val newSegments = if (isDirty) {
if (isArabic(paragraph)) {
segments.asReversed().toImmutableList()
} else {
segments
}
ParagraphState(segments, isRTL)
} else {
persistentListOf<Segment>(RegularTextSegment(paragraph))
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
@@ -66,6 +67,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
TextNoteEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ClassifiedsEvent.kind,
LongTextNoteEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
@@ -89,7 +91,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -27,6 +27,7 @@ fun String.firstFullCharOld(): String {
fun String.firstFullChar(): String {
var isInJoin = false
var hasHadSecondChance = false
var start = 0
var previousCharLength = 0
var next: Int
@@ -48,8 +49,14 @@ fun String.firstFullChar(): String {
isInJoin = true
} else {
// stops when two chars are not joined together
if ((previousCharLength > 0) && (!isInJoin) && Character.charCount(codePoint) == 1) {
break
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else {
hasHadSecondChance = false
}
isInJoin = false
@@ -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
}
}
@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class ClassifiedsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
Price(it[1], it.getOrNull(2), it.getOrNull(3))
}
fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1)
fun publishedAt() = try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
null
}
companion object {
const val kind = 30402
fun create(
dTag: String,
title: String?,
image: String?,
summary: String?,
price: Price?,
location: String?,
publishedAt: Long?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ClassifiedsEvent {
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", dTag))
title?.let { tags.add(listOf("title", it)) }
image?.let { tags.add(listOf("image", it)) }
summary?.let { tags.add(listOf("summary", it)) }
price?.let {
if (it.frequency != null && it.currency != null) {
tags.add(listOf("price", it.amount, it.currency, it.frequency))
} else if (it.currency != null) {
tags.add(listOf("price", it.amount, it.currency))
} else {
tags.add(listOf("price", it.amount))
}
}
location?.let { tags.add(listOf("location", it)) }
publishedAt?.let { tags.add(listOf("publishedAt", it.toString())) }
title?.let { tags.add(listOf("title", it)) }
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return ClassifiedsEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Price(val amount: String, val currency: String?, val frequency: String?)
@@ -267,6 +267,7 @@ open class Event(
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -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)
}
}
@@ -17,16 +17,18 @@ import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
@@ -136,7 +138,6 @@ private fun RenderRegular(
val currentTextStyle = LocalTextStyle.current
val textStyle = currentTextStyle.copy(
textDirection = TextDirection.Content,
lineHeight = 1.4.em,
color = currentTextStyle.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
@@ -144,36 +145,58 @@ private fun RenderRegular(
)
MeasureSpaceWidth() { spaceWidth ->
Column {
Column() {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
) {
paragraph.words.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
}
@@ -238,8 +261,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 +279,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 = onDialog)
} else {
VideoView(videoUri, description, null, onDialog)
VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog)
}
}
}
@@ -105,10 +114,12 @@ fun VideoView(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog)
VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -118,7 +129,9 @@ fun VideoView1(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
@@ -133,7 +146,7 @@ fun VideoView1(
}
exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
DisposableEffect(Unit) {
@@ -151,10 +164,12 @@ fun VideoView(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog)
val (_, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -166,12 +181,29 @@ fun VideoView1(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
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(
if (alwaysShowVideo) { true } else {
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 +228,11 @@ fun VideoView1(
prepare()
}
RenderVideoPlayer(exoPlayerData, thumb, onDialog)
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
@@ -230,6 +266,7 @@ data class VideoThumb(
private fun RenderVideoPlayer(
playerData: VideoPlayer,
thumbData: VideoThumb?,
automaticallyStartPlayback: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
@@ -241,7 +278,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,53 @@ 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?,
alwayShowImage: Boolean = false
) {
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(
if (alwayShowImage) { true } else {
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
}
)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
@@ -236,17 +268,26 @@ 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 +297,26 @@ private fun LocalImageView(
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
mainImageModifier: Modifier,
accountViewModel: AccountViewModel?,
alwayShowImage: Boolean = false
) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
val settings = accountViewModel?.account?.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val showImage = remember {
mutableStateOf(
if (alwayShowImage) { true } else {
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
}
)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
@@ -280,17 +338,45 @@ 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 +387,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 +427,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 +569,12 @@ 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 +616,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(
@@ -535,7 +636,10 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
SaveToGallery(
localFile = myContent.localFile,
mimeType = myContent.mimeType
)
}
}
}
@@ -544,23 +648,28 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
@Composable
fun RenderImageOrVideo(content: ZoomableContent) {
private 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, alwayShowImage = true)
} 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, alwaysShowVideo = true)
}
} else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier)
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
} 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,
alwaysShowVideo = true
)
}
}
}
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@@ -42,7 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackSelectionEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
@@ -15,7 +16,15 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : Fee
override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
.filter {
it.author == user &&
(
it.event !is PeopleListEvent &&
it.event !is BookmarkListEvent &&
it.event !is AppRecommendationEvent &&
it.event !is EmojiPackSelectionEvent
)
}
return user.notes
.plus(longFormNotes)
@@ -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()
@@ -586,7 +590,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
@@ -700,7 +704,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
@@ -553,7 +553,7 @@ private fun StatusRow(
Column(modifier = ReactionRowHeightChat) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
Spacer(modifier = DoubleHorzSpacer)
@@ -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
@@ -97,6 +98,7 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackEvent
@@ -169,6 +171,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
@@ -712,7 +715,7 @@ private fun ShortCommunityActionOptions(
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
@@ -840,7 +843,13 @@ fun ClickableNote(
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, accountViewModel.userProfile())?.let {
val redirectToNote =
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull() ?: baseNote
} else {
baseNote
}
routeFor(redirectToNote, accountViewModel.userProfile())?.let {
nav(it)
}
}
@@ -998,7 +1007,8 @@ private fun RenderNoteRow(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (baseNote.event) {
val noteEvent = baseNote.event
when (noteEvent) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
@@ -1062,6 +1072,14 @@ private fun RenderNoteRow(
)
}
is ClassifiedsEvent -> {
RenderClassifieds(
noteEvent,
baseNote,
accountViewModel
)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -1286,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(
@@ -1337,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))
@@ -1962,7 +1980,7 @@ public fun RenderEmojiPack(
}
}
Box {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
emojisToShow.forEach { emoji ->
if (onClick != null) {
@@ -2046,14 +2064,14 @@ private fun EmojiListOptions(
@Composable
fun RemoveButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.remove), color = Color.White)
}
@@ -2069,7 +2087,7 @@ fun AddButton(text: Int = R.string.add, onClick: () -> Unit) {
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
@@ -3069,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)
}
}
}
@@ -3155,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)
}
}
}
@@ -3225,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
)
}
}
@@ -3354,7 +3374,8 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
) {
VideoView(
videoUri = media,
description = subject
description = subject,
accountViewModel = accountViewModel
)
}
} else {
@@ -3404,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(
@@ -3442,6 +3473,106 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
}
}
@Composable
private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountViewModel: AccountViewModel) {
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { noteEvent.title() }
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
val price = remember(noteEvent) { noteEvent.price() }
val location = remember(noteEvent) { noteEvent.location() }
Row(
modifier = Modifier
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column {
Row() {
image?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: CreateImageHeader(note, accountViewModel)
}
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
price?.let {
val priceTag = remember(noteEvent) {
if (price.frequency != null && price.currency != null) {
"${price.amount} ${price.currency}/${price.frequency}"
} else if (price.currency != null) {
"${price.amount} ${price.currency}"
} else {
price.amount
}
}
Text(
text = priceTag,
maxLines = 1,
color = MaterialTheme.colors.primary,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(start = 5.dp)
}
)
}
}
if (summary != null || location != null) {
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), verticalAlignment = Alignment.CenterVertically) {
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier
.weight(1f),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
location?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 5.dp)
)
}
}
}
Spacer(modifier = DoubleVertSpacer)
}
}
}
@Composable
fun CreateImageHeader(
note: Note,
@@ -206,7 +206,7 @@ private fun InnerReactionRow(
) {
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
}
}
Log.d("Rendering Metrics", "Reaction Likes: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
@@ -713,6 +713,7 @@ fun LikeReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp,
iconFontSize: TextUnit = Font14SP
@@ -755,7 +756,11 @@ fun LikeReaction(
LikeText(baseNote, grayTint)
if (wantsToChangeReactionSymbol) {
UpdateReactionTypeDialog({ wantsToChangeReactionSymbol = false }, accountViewModel = accountViewModel)
UpdateReactionTypeDialog(
{ wantsToChangeReactionSymbol = false },
accountViewModel = accountViewModel,
nav
)
}
if (wantsToReact) {
@@ -927,7 +932,7 @@ private fun likeClick(
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
context.getString(R.string.login_with_a_private_key_to_like_posts),
Toast.LENGTH_SHORT
)
.show()
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -32,6 +33,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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
@@ -67,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
var nextChoice by mutableStateOf(TextFieldValue(""))
@@ -117,7 +120,11 @@ class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
fun UpdateReactionTypeDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val postViewModel: UpdateReactionTypeViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex,
factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account)
@@ -227,7 +234,10 @@ fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, acco
}
}
EmojiSelector(accountViewModel) {
EmojiSelector(
accountViewModel = accountViewModel,
nav = nav
) {
postViewModel.addChoice(it)
}
}
@@ -297,7 +307,7 @@ private fun RenderReactionOption(
}
@Composable
private fun EmojiSelector(accountViewModel: AccountViewModel, onClick: ((EmojiUrl) -> Unit)? = null) {
private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) {
LoadAddressableNote(
aTag = ATag(
EmojiPackSelectionEvent.kind,
@@ -312,14 +322,14 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, onClick: ((EmojiUr
}.distinctUntilChanged().observeAsState((usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses())
collections?.let {
EmojiCollectionGallery(it, accountViewModel, onClick)
EmojiCollectionGallery(it, accountViewModel, nav, onClick)
}
}
}
}
@Composable
fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: AccountViewModel, onClick: ((EmojiUrl) -> Unit)? = null) {
fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) {
val color = MaterialTheme.colors.background
val bgColor = remember { mutableStateOf(color) }
@@ -331,7 +341,7 @@ fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: Accou
itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item ->
LoadAddressableNote(aTag = item) {
it?.let {
WatchAndRenderNote(it, bgColor, accountViewModel, onClick)
WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick)
}
}
}
@@ -340,16 +350,31 @@ fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: Accou
@Composable
private fun WatchAndRenderNote(
it: AddressableNote,
emojiPack: AddressableNote,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onClick: ((EmojiUrl) -> Unit)?
) {
RenderEmojiPack(
baseNote = it,
actionable = false,
backgroundColor = bgColor,
accountViewModel = accountViewModel,
onClick = onClick
)
val scope = rememberCoroutineScope()
Column(
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
routeFor(emojiPack, accountViewModel.userProfile())?.let {
nav(it)
}
}
}
) {
RenderEmojiPack(
baseNote = emojiPack,
actionable = false,
backgroundColor = bgColor,
accountViewModel = accountViewModel,
onClick = onClick
)
}
}
@@ -427,7 +427,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text("Block / Report")
Text(stringResource(R.string.block_report))
}
}
Divider()
@@ -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
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
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.Box
@@ -40,6 +41,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -48,6 +50,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackEvent
@@ -74,21 +78,9 @@ 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
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
@@ -352,40 +344,9 @@ fun NoteMaster(
if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
Column {
noteEvent.image()?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.title()?.let {
Text(
text = it,
fontSize = 28.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.summary()?.let {
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
color = Color.Gray
)
}
}
}
RenderLongFormHeaderForThread(noteEvent)
} else if (noteEvent is ClassifiedsEvent) {
RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel)
}
Row(
@@ -398,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) {
@@ -492,3 +459,142 @@ fun NoteMaster(
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
@Composable
private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent,
note: Note,
accountViewModel: AccountViewModel
) {
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { noteEvent.title() }
val summary =
remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
val price = remember(noteEvent) { noteEvent.price() }
val location = remember(noteEvent) { noteEvent.location() }
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
Column {
Row() {
image?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: CreateImageHeader(note, accountViewModel)
}
Row(
Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
price?.let {
val priceTag = remember(noteEvent) {
if (price.frequency != null && price.currency != null) {
"${price.amount} ${price.currency}/${price.frequency}"
} else if (price.currency != null) {
"${price.amount} ${price.currency}"
} else {
price.amount
}
}
Text(
text = priceTag,
maxLines = 1,
color = MaterialTheme.colors.primary,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(start = 5.dp)
}
)
}
}
summary?.let {
Row(
Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier.weight(1f),
color = Color.Gray,
overflow = TextOverflow.Ellipsis
)
}
}
location?.let {
Row(
Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = it,
style = MaterialTheme.typography.caption,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
Column {
noteEvent.image()?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.title()?.let {
Text(
text = it,
fontSize = 28.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.summary()?.let {
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
color = Color.Gray
)
}
}
}
}
@@ -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
)
}
}
@@ -884,7 +885,7 @@ private fun ShortChannelActionOptions(
var popupExpanded by remember { mutableStateOf(false) }
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
@@ -947,7 +948,7 @@ private fun LiveChannelActionOptions(
Spacer(modifier = StdHorzSpacer)
}
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, 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)
}
@@ -396,7 +396,7 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = Size35dp, 28.sp)
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, nav, iconSize = 40.dp, heartSize = Size35dp, 28.sp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp)
ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter)
}
@@ -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>
+462
View File
@@ -0,0 +1,462 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">QRコードに向けてください</string>
<string name="show_qr">QRコードを表示</string>
<string name="profile_image">プロフィール画像</string>
<string name="scan_qr">QRコードをスキャン</string>
<string name="show_anyway">それでも表示する</string>
<string name="post_was_flagged_as_inappropriate_by">"このユーザによって投稿が不適切なものとしてマークされています: "</string>
<string name="post_not_found">投稿が見つかりません</string>
<string name="channel_image">チャンネル画像</string>
<string name="referenced_event_not_found">参照先のイベントが見つかりません</string>
<string name="could_not_decrypt_the_message">メッセージが復号できませんでした</string>
<string name="group_picture">グループ画像</string>
<string name="explicit_content">露骨なコンテンツ</string>
<string name="spam">スパム</string>
<string name="impersonation">なりすまし</string>
<string name="illegal_behavior">不法行為</string>
<string name="unknown">不明</string>
<string name="relay_icon">リレーアイコン</string>
<string name="unknown_author">不明な作者</string>
<string name="copy_text">テキストをコピー</string>
<string name="copy_user_pubkey">ユーザIDをコピー</string>
<string name="copy_note_id">投稿IDをコピー</string>
<string name="broadcast">ブロードキャスト</string>
<string name="request_deletion">削除をリクエスト</string>
<string name="block_report">ブロック / 通報</string>
<string name="block_hide_user">ユーザをブロックして非表示</string>
<string name="report_spam_scam">スパム / 詐欺を通報</string>
<string name="report_impersonation">なりすましを通報</string>
<string name="report_explicit_content">露骨なコンテンツを通報</string>
<string name="report_illegal_behaviour">不法行為を通報</string>
<string name="login_with_a_private_key_to_be_able_to_reply">秘密鍵でログインするとリプライできます</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">秘密鍵でログインすると投稿をブーストできます</string>
<string name="login_with_a_private_key_to_like_posts">秘密鍵でログインするといいねができます</string>
<string name="no_zap_amount_setup_long_press_to_change">ザップ額が未設定です。長押しで変更</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">秘密鍵でログインするとザップすることができます</string>
<string name="login_with_a_private_key_to_be_able_to_follow">秘密鍵でログインするとフォローすることができます</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">秘密鍵でログインするとフォロー解除することができます</string>
<string name="zaps">ザップ</string>
<string name="view_count">表示回数</string>
<string name="boost">ブースト</string>
<string name="boosted">ブーストされました</string>
<string name="quote">引用</string>
<string name="new_amount_in_sats">新しいザップ額 (sats)</string>
<string name="add">追加</string>
<string name="replying_to">"リプライ先: "</string>
<string name="and">" と "</string>
<string name="in_channel">"in channel "</string>
<string name="profile_banner">プロフィールバナー</string>
<string name="following">" フォロー"</string>
<string name="followers">" フォロワー"</string>
<string name="profile">プロフィール</string>
<string name="security_filters">セキュリティフィルタ</string>
<string name="log_out">ログアウト</string>
<string name="show_more">続きを表示</string>
<string name="lightning_invoice">Lightningインボイス</string>
<string name="pay">支払う</string>
<string name="lightning_tips">Lightningチップ</string>
<string name="note_to_receiver">Note to Receiver</string>
<string name="thank_you_so_much">ありがとうございます!</string>
<string name="amount_in_sats">金額 (sats)</string>
<string name="send_sats">satsを送る</string>
<string name="error_parsing_preview_for">%1$sのプレビューのパースに失敗しました: %2$s</string>
<string name="preview_card_image_for">%1$sのプレビューカード画像</string>
<string name="new_channel">新しいチャンネル</string>
<string name="channel_name">チャンネル名</string>
<string name="my_awesome_group">私の素晴らしいグループ</string>
<string name="picture_url">画像URL</string>
<string name="description">説明</string>
<string name="about_us">"私たちについて… "</string>
<string name="what_s_on_your_mind">何か思いついた?</string>
<string name="post">送信</string>
<string name="save">保存</string>
<string name="create">作成</string>
<string name="cancel">キャンセル</string>
<string name="failed_to_upload_the_image">画像のアップロードに失敗しました</string>
<string name="relay_address">リレーアドレス</string>
<string name="posts">投稿</string>
<string name="bytes">バイト</string>
<string name="errors">エラー</string>
<string name="home_feed">ホームフィード</string>
<string name="private_message_feed">非公開メッセージフィード</string>
<string name="public_chat_feed">公開チャットフィード</string>
<string name="global_feed">グローバルフィード</string>
<string name="search_feed">検索フィード</string>
<string name="add_a_relay">リレーを追加</string>
<string name="display_name">表示名</string>
<string name="my_display_name">わたしの表示名</string>
<string name="username">ユーザ名</string>
<string name="my_username">わたしのユーザ名</string>
<string name="about_me">自己紹介</string>
<string name="avatar_url">アバターURL</string>
<string name="banner_url">バナーURL</string>
<string name="website_url">ウェブサイトURL</string>
<string name="ln_address">LNアドレス</string>
<string name="ln_url_outdated">LN URL (旧式)</string>
<string name="image_saved_to_the_gallery">画像を保存しました</string>
<string name="failed_to_save_the_image">画像の保存に失敗しました</string>
<string name="upload_image">画像をアップロード</string>
<string name="uploading">アップロード中…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">satsを受け取るためのLightningアドレスが設定されていません</string>
<string name="reply_here">"ここに返信… "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">投稿IDをクリップボードにコピー</string>
<string name="copy_channel_id_note_to_the_clipboard">チャンネルIDをクリップボードにコピー</string>
<string name="edits_the_channel_metadata">チャンネルのメタデータを編集</string>
<string name="join">参加</string>
<string name="known">知り合い</string>
<string name="new_requests">新しいリクエスト</string>
<string name="blocked_users">ブロック済みユーザ</string>
<string name="new_threads">新規スレッド</string>
<string name="conversations">会話</string>
<string name="notes">投稿</string>
<string name="replies">リプライ</string>
<string name="follows">フォロー</string>
<string name="reports">通報</string>
<string name="more_options">詳細なオプション</string>
<string name="relays">" リレー"</string>
<string name="website">ウェブサイト</string>
<string name="lightning_address">Lightningアドレス</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">バックアップ用に nsec ID (パスワード) をクリップボードにコピー</string>
<string name="copy_private_key_to_the_clipboard">クリップボードに秘密鍵をコピー</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">公開鍵をクリップボードにコピー</string>
<string name="copy_public_key_npub_to_the_clipboard">公開鍵 (NPub) をクリップボードにコピー</string>
<string name="send_a_direct_message">ダイレクトメッセージを送信</string>
<string name="edits_the_user_s_metadata">ユーザのメタデータを編集</string>
<string name="follow">フォロー</string>
<string name="follow_back">フォローバック</string>
<string name="unblock">ブロック解除</string>
<string name="copy_user_id">ユーザIDをコピー</string>
<string name="unblock_user">ユーザのブロックを解除</string>
<string name="npub_hex_username">npub、ユーザ名、テキスト</string>
<string name="clear">消去</string>
<string name="app_logo">アプリのロゴ</string>
<string name="nsec_npub_hex_private_key">nsec… または npub…</string>
<string name="show_password">パスワードを表示</string>
<string name="hide_password">パスワードを非表示</string>
<string name="invalid_key">無効な鍵</string>
<string name="i_accept_the">"同意する: "</string>
<string name="terms_of_use">利用規約</string>
<string name="acceptance_of_terms_is_required">利用規約の同意が必要です</string>
<string name="key_is_required">鍵が必要です</string>
<string name="login">ログイン</string>
<string name="generate_a_new_key">新しい鍵を生成</string>
<string name="loading_feed">フィードを読み込み中</string>
<string name="error_loading_replies">"リプライ読み込みエラー: "</string>
<string name="try_again">後ほどお試しください</string>
<string name="feed_is_empty">フィードが空です</string>
<string name="refresh">更新</string>
<string name="created">が作成しました:</string>
<string name="with_description_of">説明:</string>
<string name="and_picture">画像:</string>
<string name="changed_chat_name_to">がチャンネル情報を変更しました:</string>
<string name="description_to">説明:</string>
<string name="and_picture_to">画像:</string>
<string name="leave">退出</string>
<string name="unfollow">フォロー解除</string>
<string name="channel_created">チャンネルが作成されました</string>
<string name="channel_information_changed_to">チャンネル情報が次のように変更されました:</string>
<string name="public_chat">公開チャット</string>
<string name="posts_received">投稿を受信</string>
<string name="remove">削除</string>
<string name="translations_auto">自動</string>
<string name="translations_translated_from">翻訳元:</string>
<string name="translations_to">翻訳先:</string>
<string name="translations_show_in_lang_first">最初に%1$sで表示</string>
<string name="translations_always_translate_to_lang">常に%1$sに翻訳</string>
<string name="translations_never_translate_from_lang">%1$sを翻訳しない</string>
<string name="never">never</string>
<string name="now"></string>
<string name="h">時間</string>
<string name="m"></string>
<string name="d"></string>
<string name="nudity">ヌード</string>
<string name="profanity_hateful_speech">誹謗中傷 / ヘイトスピーチ</string>
<string name="report_hateful_speech">ヘイトスピーチを通報</string>
<string name="report_nudity_porn">ヌード / ポルノを通報</string>
<string name="others">その他</string>
<string name="mark_all_known_as_read">すべての「知り合い」を既読にする</string>
<string name="mark_all_new_as_read">すべての「新規リクエスト」を既読にする</string>
<string name="mark_all_as_read">すべて既読にする</string>
<string name="backup_keys">鍵をバックアップ</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## 鍵のバックアップと安全に利用するためのTips
\n\nあなたのアカウントは秘密鍵によって保護されています。秘密鍵は **nsec1** から始まる長いランダムな文字列です。秘密鍵にアクセスできる人は、あなたのアカウントを使用してコンテンツを公開することができます。
\n\n- 信頼できないウェブサイトやソフトウェアには、秘密鍵を **渡さない** でください。
\n- Amethystの開発者があなたに秘密鍵をお聞きすることは **ありません** 。
\n- アカウント復旧のために、秘密鍵を安全な方法で **バックアップして** ください 。パスワードマネージャーの利用を推奨しています。
</string>
<string name="secret_key_copied_to_clipboard">秘密鍵 (nsec) をクリップボードにコピーしました</string>
<string name="copy_my_secret_key">秘密鍵をコピー</string>
<string name="biometric_authentication_failed">認証に失敗しました</string>
<string name="biometric_error">エラー</string>
<string name="badge_created_by">%1$s によって作成</string>
<string name="badge_award_image_for">%1$sのバッジ授与画像</string>
<string name="new_badge_award_notif">新しいバッジが授与されました</string>
<string name="award_granted_to">バッジ授与が承認されました:</string>
<string name="copied_note_text_to_clipboard">テキストがクリップボードにコピーされました</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">\@npubをクリップボードにコピーしました</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">投稿ID (@note1) をクリップボードにコピーしました</string>
<string name="select_text_dialog_top">テキストを選択</string>
<string name="private_conversation_notification">"&lt;プライベートメッセージの復号に失敗しました&gt;\n\n%1$s と %2$s のプライベート/暗号化された会話であなたが言及されました。"</string>
<string name="account_switch_add_account_dialog_title">アカウントを追加</string>
<string name="drawer_accounts">アカウント</string>
<string name="account_switch_select_account">アカウントを選択</string>
<string name="account_switch_add_account_btn">新規アカウントを追加</string>
<string name="account_switch_active_account">アクティブアカウント</string>
<string name="account_switch_has_private_key">秘密鍵あり</string>
<string name="account_switch_pubkey_only">読み取り専用、秘密鍵なし</string>
<string name="back">戻る</string>
<string name="quick_action_select">選択</string>
<string name="quick_action_share_browser_link">ブラウザリンクを共有</string>
<string name="quick_action_share">共有</string>
<string name="quick_action_copy_user_id">ユーザID</string>
<string name="quick_action_copy_note_id">投稿ID</string>
<string name="quick_action_copy_text">テキストをコピー</string>
<string name="quick_action_delete">削除</string>
<string name="quick_action_unfollow">フォロー解除</string>
<string name="quick_action_follow">フォロー</string>
<string name="quick_action_request_deletion_alert_title">削除をリクエスト</string>
<string name="quick_action_request_deletion_alert_body">Amethystは、現在接続しているリレーから投稿を削除するようリクエストします。これらのリレーや、投稿が保存されている可能性のある他のリレーから、あなたの投稿が永久に削除される保証はありません。</string>
<string name="quick_action_block_dialog_btn">ブロック</string>
<string name="quick_action_delete_dialog_btn">削除</string>
<string name="quick_action_block">ブロック</string>
<string name="quick_action_report">通報</string>
<string name="quick_action_delete_button">削除</string>
<string name="quick_action_dont_show_again_button">再度表示しない</string>
<string name="report_dialog_spam">スパムまたは詐欺</string>
<string name="report_dialog_profanity">誹謗中傷またはヘイトスピーチ</string>
<string name="report_dialog_impersonation">悪質ななりすまし</string>
<string name="report_dialog_nudity">ヌードまたは生々しいコンテンツ</string>
<string name="report_dialog_illegal">不法行為</string>
<string name="report_dialog_blocking_a_user">ユーザーをブロックするとアプリ内でそのユーザーのコンテンツが非表示になります。ブロックされたユーザーは、あなたの投稿を今までどおり閲覧可能です。ブロック済みユーザーはセキュリティフィルタ画面に表示されます。</string>
<string name="report_dialog_block_hide_user_btn">ユーザをブロックして非表示</string>
<string name="report_dialog_report_btn">通報</string>
<string name="report_dialog_reminder_public">投稿したすべての通報は誰もが閲覧可能です。</string>
<string name="report_dialog_additional_reason_placeholder">任意でレポートに関する追加情報を提供する</string>
<string name="report_dialog_additional_reason_label">追加の情報</string>
<string name="report_dialog_select_reason_label">理由</string>
<string name="report_dialog_select_reason_placeholder">理由を選択…</string>
<string name="report_dialog_post_report_btn">通報を送信</string>
<string name="report_dialog_title">ブロックと通報</string>
<string name="block_only">ブロック</string>
<string name="bookmarks">ブックマーク</string>
<string name="private_bookmarks">非公開ブックマーク</string>
<string name="public_bookmarks">公開ブックマーク</string>
<string name="add_to_private_bookmarks">非公開ブックマークに追加</string>
<string name="add_to_public_bookmarks">公開ブックマークに追加</string>
<string name="remove_from_private_bookmarks">非公開ブックマークから削除</string>
<string name="remove_from_public_bookmarks">公開ブックマークから削除</string>
<string name="wallet_connect_service">Wallet Connectサービス</string>
<string name="wallet_connect_service_explainer">アプリから離脱することなくザップするためにNostrシークレットを設定できます。シークレットは安全に保管し、可能であればプライベートリレーを使用してください</string>
<string name="wallet_connect_service_pubkey">Wallet Connectの公開鍵</string>
<string name="wallet_connect_service_relay">Wallet Connectのリレー</string>
<string name="wallet_connect_service_secret">Wallet Connectの秘密鍵</string>
<string name="wallet_connect_service_show_secret">秘密鍵を表示</string>
<string name="wallet_connect_service_secret_placeholder">nsec または hex 形式の秘密鍵</string>
<string name="pledge_amount_in_sats">誓約額 (sats)</string>
<string name="post_poll">投票を送信</string>
<string name="poll_heading_required">必須のフィールド:</string>
<string name="poll_zap_recipients">ザップ受取人</string>
<string name="poll_primary_description">投票の説明…</string>
<string name="poll_option_index">選択肢%s</string>
<string name="poll_option_description">投票の選択肢の説明</string>
<string name="poll_heading_optional">任意のフィールド:</string>
<string name="poll_zap_value_min">最小ザップ額</string>
<string name="poll_zap_value_max">最大ザップ額</string>
<string name="poll_consensus_threshold">コンセンサス</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">終了まで</string>
<string name="poll_closing_time_days"></string>
<string name="poll_is_closed">投票は締め切られました</string>
<string name="poll_zap_amount">ザップ額</string>
<string name="one_vote_per_user_on_atomic_votes">このタイプの投票では、1ユーザにつき1回のみ投票可能です</string>
<string name="looking_for_event">イベント %1$s を探しています</string>
<string name="custom_zaps_add_a_message">公開メッセージを追加</string>
<string name="custom_zaps_add_a_message_private">プライベートメッセージを追加</string>
<string name="custom_zaps_add_a_message_nonzap">インボイスメッセージを追加</string>
<string name="custom_zaps_add_a_message_example">おつかれさまでした!</string>
<string name="lightning_create_and_add_invoice">作成して追加</string>
<string name="poll_author_no_vote">投票の作成者が投票することはできません。</string>
<string name="hash_verification_passed">画像は投稿時と同じです</string>
<string name="hash_verification_failed">画像が変更されています。作者は変更に気づいていないかもしれません</string>
<string name="content_description_add_image">画像を追加</string>
<string name="content_description_add_video">動画を追加</string>
<string name="content_description_add_document">ドキュメントを追加</string>
<string name="add_content">メッセージに追加</string>
<string name="content_description">コンテンツの説明</string>
<string name="content_description_example">夕暮れの白い砂浜に浮かぶ青いボート</string>
<string name="zap_type">ザップの種類</string>
<string name="zap_type_explainer">ザップ種別のオプション</string>
<string name="zap_type_public">公開</string>
<string name="zap_type_public_explainer">誰もが取引とメッセージを閲覧できます</string>
<string name="zap_type_private">プライベート</string>
<string name="zap_type_private_explainer">送金者と受取人だけが互いにメッセージを閲覧できます</string>
<string name="zap_type_anonymous">匿名</string>
<string name="zap_type_anonymous_explainer">受取人を含め、すべての人は送金者を知ることができません</string>
<string name="zap_type_nonzap">非ザップ</string>
<string name="zap_type_nonzap_explainer">Nostrを経由せずにLightning支払いを行います</string>
<string name="file_server">ファイルサーバ</string>
<string name="zap_forward_lnAddress">LNアドレス または @ユーザ名</string>
<string name="upload_server_imgur">imgur.com - 信頼済み</string>
<string name="upload_server_imgur_explainer">Imgurはファイルの変更が可能です</string>
<string name="upload_server_nostrimg">nostrimg.com - 信頼済み</string>
<string name="upload_server_nostrimg_explainer">NostrImgはファイルの変更が可能です</string>
<string name="upload_server_nostrbuild">nostr.build - 信頼済み</string>
<string name="upload_server_nostrbuild_explainer">Nostr.buildはファイルの変更が可能です</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - 信頼済み</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.devはファイルの変更が可能です</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - 信頼済み</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.meはファイルの変更が可能です</string>
<string name="upload_server_imgur_nip94">検証可能な Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Imgurによるファイルの変更をチェックします。新規NIP: 他のクライアントでは表示できない可能性があります</string>
<string name="upload_server_nostrimg_nip94">検証可能な NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImgによるファイルの変更をチェックします。新規NIP: 他のクライアントでは表示できない可能性があります</string>
<string name="upload_server_nostrbuild_nip94">検証可能な Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.buildによるファイルの変更をチェックします。新規NIP: 他のクライアントでは表示できない可能性があります</string>
<string name="upload_server_nostrfilesdev_nip94">検証可能な Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.devによるファイルの変更をチェックします。新規NIP: 他のクライアントでは表示できない可能性があります</string>
<string name="upload_server_nostrcheckme_nip94">検証可能な Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.meによるファイルの変更をチェックします。新規NIP: 他のクライアントでは表示できない可能性があります</string>
<string name="upload_server_relays_nip95">あなたのリレー (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ファイルはあなたのリレーによってホストされます。新規NIP: リレーがサポートしているか確認してください</string>
<string name="connect_via_tor_short">Tor/Orbotを設定</string>
<string name="connect_via_tor">Orbotの設定を通して接続</string>
<string name="do_you_really_want_to_disable_tor_title">Orbot/Torから切断しますか?</string>
<string name="do_you_really_want_to_disable_tor_text">データはすぐに通常のネットワークで送信されます</string>
<string name="yes">はい</string>
<string name="no">いいえ</string>
<string name="follow_list_selection">フォローリスト</string>
<string name="follow_list_kind3follows">すべてのフォロー</string>
<string name="follow_list_global">グローバル</string>
<string name="connect_through_your_orbot_setup_markdown">
## Orbotを利用してTor経由で接続する
\n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)をインストール
\n2. Orbotを立ち上げる
\n3. OrbotでSocksポート番号を確認する。デフォルトは9050
\n4. 必要に応じてOrbotでポート番号を変更する
\n5. この画面でSocksポート番号を設定する
\n6. アクティベートボタンを押して、Orbotをプロキシとして使用する
</string>
<string name="orbot_socks_port">OrbotのSocksポート番号</string>
<string name="invalid_port_number">無効なポート番号</string>
<string name="use_orbot">Orbotを使用</string>
<string name="disconnect_from_your_orbot_setup">Tor/Orbotを切断</string>
<string name="app_notification_dms_channel_name">プライベートメッセージ</string>
<string name="app_notification_dms_channel_description">プライベートメッセージが届いたら通知する</string>
<string name="app_notification_zaps_channel_name">ザップを受け取りました</string>
<string name="app_notification_zaps_channel_description">誰かがあなたにザップしたら通知する</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">%1$sから</string>
<string name="app_notification_zaps_channel_message_for">%1$sに</string>
<string name="reply_notify">"通知: "</string>
<string name="channel_list_join_conversation">会話に参加</string>
<string name="channel_list_user_or_group_id">ユーザIDまたはグループID</string>
<string name="channel_list_user_or_group_id_demo">npub、nevent または hex</string>
<string name="channel_list_create_channel">作成</string>
<string name="channel_list_join_channel">参加</string>
<string name="today">今日</string>
<string name="content_warning">コンテンツ警告</string>
<string name="content_warning_explanation">この投稿には、一部の人々が不快や不穏に感じる可能性のあるセンシティブな内容が含まれています</string>
<string name="content_warning_hide_all_sensitive_content">センシティブなコンテンツを常に非表示</string>
<string name="content_warning_show_all_sensitive_content">センシティブなコンテンツを常に表示</string>
<string name="content_warning_see_warnings">コンテンツ警告を常に表示</string>
<string name="recommended_apps">"推奨: "</string>
<string name="filter_spam_from_strangers">見知らぬユーザからのスパムをフィルタリングする</string>
<string name="warn_when_posts_have_reports_from_your_follows">あなたがフォローしている人に通報された投稿を警告する</string>
<string name="new_reaction_symbol">新しいリアクションシンボル</string>
<string name="no_reaction_type_setup_long_press_to_change">リアクションタイプが選択されていません。長押しして変更</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">この投稿の目標募金額を追加します。サポートするクライアントは、寄付を促すためにこれをプログレスバーとして表示することができます</string>
<string name="zapraiser_target_amount_in_sats">目標募金額 (sats)</string>
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats to goal</string>
<string name="read_from_relay">リレーから読み込み</string>
<string name="write_to_relay">リレーに書き込み</string>
<string name="an_error_ocurred_trying_to_get_relay_information">%1$sからリレー情報を取得する際にエラーが発生しました</string>
<string name="owner">管理者</string>
<string name="version">バージョン</string>
<string name="software">ソフトウェア</string>
<string name="contact">連絡先</string>
<string name="supports">サポートしているNIP</string>
<string name="admission_fees">利用料</string>
<string name="payments_url">決済URL</string>
<string name="limitations">制限</string>
<string name="countries"></string>
<string name="languages">言語</string>
<string name="tags">タグ</string>
<string name="posting_policy">投稿ポリシー</string>
<string name="message_length">メッセージ長</string>
<string name="subscriptions">サブスクリプション</string>
<string name="filters">フィルタ</string>
<string name="subscription_id_length">サブスクリプションID長</string>
<string name="minimum_prefix">最小プレフィックス</string>
<string name="maximum_event_tags">最大タグ数</string>
<string name="content_length">コンテンツ長</string>
<string name="minimum_pow">最小PoW</string>
<string name="auth">認証</string>
<string name="payment">決済</string>
<string name="cashu">Cashuトークン</string>
<string name="cashu_redeem">償還</string>
<string name="live_stream_live_tag">ライブ</string>
<string name="live_stream_offline_tag">オフライン</string>
<string name="live_stream_ended_tag">配信終了</string>
<string name="live_stream_planned_tag">配信予定</string>
<string name="live_stream_is_offline">ライブ配信はオフラインです</string>
<string name="live_stream_has_ended">ライブ配信は終了しました</string>
<string name="are_you_sure_you_want_to_log_out">ログアウトするとローカル情報はすべて削除されます。アカウントを紛失しないよう、秘密鍵のバックアップがあることを確認してください。続行しますか?</string>
<string name="followed_tags">フォロー済みタグ</string>
<string name="relay_setup">リレー</string>
<string name="discover_live">ライブ</string>
<string name="discover_community">コミュニティ</string>
<string name="discover_chat">チャット</string>
<string name="community_approved_posts">承認済みの投稿</string>
<string name="groups_no_descriptor">このグループには説明またはルールがありません。追加するには管理者に相談してください</string>
<string name="community_no_descriptor">このコミュニティには説明がありません。追加するには管理者に相談してください</string>
<string name="add_sensitive_content_label">センシティブなコンテンツ</string>
<string name="add_sensitive_content_description">コンテンツを表示する前に、センシティブなコンテンツであることを示す警告を表示します</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:windowBackground">@color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\
+14
View File
@@ -24,6 +24,7 @@
<string name="copy_note_id">Copy Note ID</string>
<string name="broadcast">Broadcast</string>
<string name="request_deletion">Request Deletion</string>
<string name="block_report">Block / Report</string>
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
<string name="report_spam_scam">Report Spam / Scam</string>
<string name="report_impersonation">Report Impersonation</string>
@@ -483,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>
+6 -6
View File
@@ -7,17 +7,17 @@
<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-config>
<locale android:name="ja"/>
</locale-config>
@@ -20,6 +20,11 @@ class CharsetTest {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar())
}
@Test
fun testUTF32JoinChar2() {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar())
}
@Test
fun testAsciiWithUTF32Char() {
Assert.assertEquals("H", "Hi\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar())
@@ -54,4 +59,9 @@ class CharsetTest {
fun testVariation() {
Assert.assertEquals("\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar())
}
@Test
fun testMultipleEmoji() {
Assert.assertEquals("\uD83E\uDEC2\uD83E\uDEC2", "\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2".firstFullChar())
}
}