Compare commits

...

42 Commits

Author SHA1 Message Date
Vitor Pamplona
ea185147b3 v0.54.4 2023-05-29 19:47:02 -04:00
Vitor Pamplona
f2db2b88c2 Improves rendering of Markdown summaries 2023-05-29 19:46:54 -04:00
Vitor Pamplona
6ea0972151 Fixes bug with rendering of markdown with user citations. 2023-05-29 19:20:33 -04:00
Vitor Pamplona
093705f3b6 v0.52.3 2023-05-29 18:06:42 -04:00
Vitor Pamplona
1972297755 adds flavor to the version descriptor in the bottom left of the drawer 2023-05-29 18:06:35 -04:00
Vitor Pamplona
ac00694690 Moves ExoPlayer to OkHttp in order to enable Tor support. 2023-05-29 18:00:29 -04:00
Vitor Pamplona
605861dc32 v0.53.2 2023-05-29 17:12:04 -04:00
Vitor Pamplona
43d508ba33 Correcting the user's room for the notification marker 2023-05-29 17:11:56 -04:00
Vitor Pamplona
e692647d58 v0.53.1 2023-05-29 16:26:16 -04:00
Vitor Pamplona
64f98e4b92 Filters Message Feed DAL to only take PrivateMessages in the Notification Bubble. 2023-05-29 16:25:35 -04:00
Vitor Pamplona
c58c20cc54 Adding immutable tag to ResizeImage class 2023-05-29 15:15:20 -04:00
Vitor Pamplona
89ddb7f4b7 v0.53.0 2023-05-29 15:05:01 -04:00
Vitor Pamplona
cb92a51191 Adds separate colors for the newItem background between light and dark themes 2023-05-29 14:58:10 -04:00
Vitor Pamplona
213b172c32 Merge pull request #431 from davotoula/add_translation_cs
Add Czech translations
2023-05-29 14:27:01 -04:00
Vitor Pamplona
cfdd470626 formatting 2023-05-29 14:25:43 -04:00
Vitor Pamplona
a5a3c62edd Moving chatroomlist to an additive filter 2023-05-29 14:25:34 -04:00
David Kaspar
c9bcfa1c17 added Czech translation 2023-05-29 18:36:51 +01:00
Vitor Pamplona
02ad85a740 Avoids running through the entire filter when collection is Empty 2023-05-28 17:53:08 -04:00
Vitor Pamplona
446273de80 Adds mute control per track on the screen. 2023-05-28 17:36:20 -04:00
Vitor Pamplona
b513877552 Optimizing UserProfile status 2023-05-28 17:33:01 -04:00
Vitor Pamplona
013eedd892 Removing logs 2023-05-28 17:32:48 -04:00
Vitor Pamplona
d2cbb27756 Separating logs for full feed updates and additive feed updates 2023-05-28 17:32:40 -04:00
Vitor Pamplona
1a477ea1c8 Refreshing user status when added to the transient spam list 2023-05-28 17:32:05 -04:00
Vitor Pamplona
83df639aff Merge pull request #430 from malimahda/main
changes to translation
2023-05-28 16:25:10 -04:00
Vitor Pamplona
f298cc6f13 Adds some documentation to make sure any NIP05 changes take into account that we are converting the entire json to lowercase. 2023-05-28 16:24:37 -04:00
Vitor Pamplona
300a70ce18 Merge remote-tracking branch 'origin/HEAD' 2023-05-28 16:09:54 -04:00
Vitor Pamplona
0f6bf1cc59 Merge pull request #428 from davotoula/bugfix_case_sensitive_nip05
Bugfix case sensitive nip05
2023-05-28 16:09:37 -04:00
Vitor Pamplona
6bc1b6b28a Loading reply from launch and not withContent 2023-05-28 16:03:15 -04:00
Vitor Pamplona
1c381f64f9 v0.52.3 2023-05-28 15:35:46 -04:00
Vitor Pamplona
5e3703e849 Moves all note states to leaf compositors 2023-05-28 15:34:13 -04:00
Vitor Pamplona
acbd41ce61 Better recomposition structure for ReactionsRow 2023-05-28 15:17:10 -04:00
Vitor Pamplona
056d00b73b Performance Improvements when rendering a channel creation note 2023-05-28 15:16:50 -04:00
L
72fc21cdc6 Update strings.xml
translated new strings
2023-05-28 21:22:33 +03:30
L
3e983cdf1c Update strings.xml
added new strings
2023-05-28 20:40:16 +03:30
L
d81af9a194 Merge branch 'vitorpamplona:main' into main 2023-05-28 17:59:35 +03:30
David Kaspar
7f6b44cd0b spelling fix 2023-05-27 23:51:13 +01:00
David Kaspar
88bad1e308 added additional trivial tests 2023-05-27 22:35:34 +01:00
David Kaspar
aa0a4d1aac Use lowercase versions of user name and retrieved json to make case insensitive match 2023-05-27 21:56:57 +01:00
David Kaspar
b0a61cce42 Added failing test
(a bit dirty using a spy for the actual "system under test" but it is the quickest way to test added functionality and mocking out complexity around coroutines and httpclient)
2023-05-27 21:42:12 +01:00
L
459e579bcb Update strings.xml
changed some word to better choices
2023-05-20 20:46:04 +03:30
L
3c069d574e Merge branch 'vitorpamplona:main' into main 2023-05-20 20:38:19 +03:30
L
d2accf7622 Update strings.xml
a few words . not urgent
2023-05-16 19:22:04 +03:30
41 changed files with 1445 additions and 546 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 182
versionName "0.52.2"
versionCode 188
versionName "0.53.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -140,6 +140,8 @@ dependencies {
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
// important for proxy / tor
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"

View File

@@ -1,5 +1,12 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
@@ -19,14 +26,30 @@ import com.vitorpamplona.amethyst.service.relays.Client
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
fun start(account: Account, context: Context) {
this.account = account
start()
start(context)
}
fun start() {
fun start(context: Context) {
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
Coil.setImageLoader {
ImageLoader.Builder(context).components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())

View File

@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.vitorpamplona.amethyst.service.HttpClient
object VideoCache {
@@ -31,7 +32,7 @@ object VideoCache {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}

View File

@@ -909,15 +909,7 @@ class Account(
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -1154,6 +1146,7 @@ class Account(
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
}
}

View File

@@ -75,14 +75,17 @@ class Nip05Verifier() {
fetchNip05Json(
nip05,
onSuccess = {
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url = try {
mapper.readTree(it)
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val user = nip05.split("@")[0].lowercase()
val hexKey = nip05url?.get("names")?.get(user)?.asText()

View File

@@ -29,6 +29,10 @@ class PrivateDmEvent(
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
}
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
@@ -15,6 +16,7 @@ object Nip19 {
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
@Immutable
data class Return(
val type: Type,
val hex: String,

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -12,15 +11,9 @@ import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -28,7 +21,7 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -52,20 +45,6 @@ class MainActivity : FragmentActivity() {
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
LocalPreferences.migrateSingleUserPrefs()
setContent {
@@ -73,7 +52,7 @@ class MainActivity : FragmentActivity() {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel()
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, startingPage)
@@ -88,11 +67,11 @@ class MainActivity : FragmentActivity() {
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
DefaultMutedSetting.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())

View File

@@ -63,7 +63,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -78,7 +77,10 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current

View File

@@ -17,18 +17,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewNoteButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, nav = nav)
NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -14,6 +15,7 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import java.util.Base64
@Immutable
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null

View File

@@ -365,40 +365,8 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
nip19References.forEach {
var baseUser by remember(it) { mutableStateOf<User?>(null) }
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
}
}
}
}
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
LaunchedEffect(key1 = noteState) {
refresh++
}
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
LaunchedEffect(key1 = userState) {
refresh++
}
}
ObserveNIP19(it) {
refresh++
}
}
@@ -423,6 +391,72 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
}
@Composable
fun ObserveNIP19(
it: Nip19.Return,
onRefresh: () -> Unit
) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
ObserveNIP19Event(it, onRefresh)
} else if (it.type == Nip19.Type.USER) {
ObserveNIP19User(it, onRefresh)
}
}
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
onRefresh: () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
}
}
baseNote?.let { note ->
val noteState by note.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
LaunchedEffect(key1 = noteState) {
onRefresh()
}
}
}
}
@Composable
private fun ObserveNIP19User(
it: Nip19.Return,
onRefresh: () -> Unit
) {
var baseUser by remember(it) { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
}
}
}
}
baseUser?.let { user ->
val userState by user.live().metadata.observeAsState()
if (userState?.user?.info != null) {
LaunchedEffect(key1 = userState) {
onRefresh()
}
}
}
}
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {

View File

@@ -64,22 +64,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var muted = mutableStateOf(true)
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, null, onDialog)
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, null, onDialog)
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current
@@ -104,7 +108,9 @@ fun VideoView(videoUri: String, description: String? = null, thumbUri: String, o
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog)
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
} else {
VideoView(videoUri, description, onDialog)
}
@@ -116,7 +122,7 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
println("loading audio with artwork $thumb")
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -146,10 +152,6 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
}
}
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect(
BoxWithConstraints() {
AndroidView(
@@ -185,8 +187,11 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
MuteButton(mutedInstance) {
mutedInstance.value = !mutedInstance.value
DefaultMutedSetting.value = mutedInstance.value
exoPlayer.volume = if (mutedInstance.value) 0f else 1f
}
}
) {
@@ -241,12 +246,14 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
private fun MuteButton(muted: MutableState<Boolean>, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
remember {
Modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
}
) {
Box(
Modifier
@@ -256,23 +263,18 @@ private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle:
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
if (muted.value) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
} else {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",

View File

@@ -1,9 +1,16 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
@@ -35,4 +42,113 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPublicMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
if (
(newNotePair.key == oldNote.channelHex()) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
sort(myNewList.toSet()).take(1000)
}
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
emptySet()
} else {
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet()
}
}
private fun filterRelevantPublicMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val followingChannels = account.followingChannels
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is ChannelMessageEvent }.forEach { newNote ->
newNote.channelHex()?.let { channelHex ->
if (channelHex in followingChannels && account.isAcceptable(newNote)) {
val lastNote = newRelevantPublicMessages.get(channelHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPublicMessages.put(channelHex, newNote)
}
} else {
newRelevantPublicMessages.put(channelHex, newNote)
}
}
}
}
return newRelevantPublicMessages
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
if (roomUserHex != null && (newNote.author?.pubkeyHex == me.pubkeyHex || roomUserHex in followingKeySet || me.hasSentMessagesTo(roomUser)) && !account.isHidden(roomUserHex)) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}

View File

@@ -11,7 +11,7 @@ abstract class FeedFilter<T> {
feed()
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects")
return feed.take(1000)
}
@@ -23,7 +23,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
@@ -34,7 +34,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
}
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -43,6 +44,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -335,6 +337,8 @@ fun ListContent(
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
}
val context = LocalContext.current
if (conectOrbotDialogOpen) {
ConnectOrbotDialog(
onClose = { conectOrbotDialogOpen = false },
@@ -342,7 +346,7 @@ fun ListContent(
conectOrbotDialogOpen = false
disconnectTorDialog = false
checked = true
enableTor(account, true, proxyPort)
enableTor(account, true, proxyPort, context = context)
},
proxyPort
)
@@ -364,7 +368,7 @@ fun ListContent(
onClick = {
disconnectTorDialog = false
checked = false
enableTor(account, false, proxyPort)
enableTor(account, false, proxyPort, context)
}
) {
Text(text = stringResource(R.string.yes))
@@ -386,13 +390,14 @@ fun ListContent(
private fun enableTor(
account: Account,
checked: Boolean,
portNumber: MutableState<String>
portNumber: MutableState<String>,
context: Context
) {
account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.pause()
ServiceManager.start()
ServiceManager.start(context)
}
@Composable
@@ -467,7 +472,7 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, nav: (String) -> Uni
) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = "v" + BuildConfig.VERSION_NAME,
text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)

View File

@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -127,17 +128,25 @@ open class LatestItem {
if (newestItem == null) {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.feed().firstOrNull { it.createdAt() != null }
filterMore(filter.feed()).firstOrNull { it.createdAt() != null }
)
} else {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
filter.sort(filterMore(filter.applyFilter(newNotes)) + newestItem).first()
)
}
return newestItemPerAccount[account.userProfile().pubkeyHex]
}
open fun filterMore(newItems: Set<Note>): Set<Note> {
return newItems
}
open fun filterMore(newItems: List<Note>): List<Note> {
return newItems
}
}
object HomeLatestItem : LatestItem() {
@@ -170,7 +179,7 @@ object NotificationLatestItem : LatestItem() {
}
}
object MessagesLatestItem {
object MessagesLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
@@ -178,13 +187,21 @@ object MessagesLatestItem {
): Boolean {
ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter)
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex)
return (note.createdAt() ?: 0) > lastTime
val lastTime = cache.load("Room/$roomUserHex")
return (newestItem?.createdAt() ?: 0) > lastTime
}
override fun filterMore(newItems: Set<Note>): Set<Note> {
return newItems.filter { it.event is PrivateDmEvent }.toSet()
}
override fun filterMore(newItems: List<Note>): List<Note> {
return newItems.filter { it.event is PrivateDmEvent }
}
}

View File

@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -64,7 +65,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -62,7 +63,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -61,7 +62,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -27,8 +27,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -66,7 +68,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
@@ -98,9 +100,11 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
MessageIcon()
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
val routeForLastRead = "Room/${(baseNote.event as? PrivateDmEvent)?.talkingWith(loggedIn.pubkeyHex)}"
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
routeForLastRead = routeForLastRead,
isBoostedNote = true,
addMarginTop = false,
parentBackgroundColor = backgroundColor,

View File

@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.Dispatchers
@@ -88,7 +89,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
}
val primaryColor = MaterialTheme.colors.primary.copy(0.12f)
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = if (isNew) {

View File

@@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
@@ -108,6 +107,7 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
@@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -153,7 +154,6 @@ fun NoteCompose(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
@@ -161,13 +161,11 @@ fun NoteCompose(
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
val noteEvent = remember(noteState) { note.event }
val baseChannel = remember(noteState) { note.channel() }
val isSensitive = remember(noteState) { note.event?.isSensitive() ?: false }
var popupExpanded by remember { mutableStateOf(false) }
if (note.event == null) {
var popupExpanded by remember { mutableStateOf(false) }
if (noteEvent == null) {
BlankNote(
remember {
modifier.combinedClickable(
@@ -178,13 +176,11 @@ fun NoteCompose(
isBoostedNote
)
note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
} else if (account.isHidden(noteForReports.author!!) || (isSensitive && account.showSensitiveContent == false)) {
// Does nothing
} else {
var showHiddenNote by remember { mutableStateOf(false) }
var showReportedNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
@@ -203,192 +199,235 @@ fun NoteCompose(
}
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
if (!isAcceptableAndCanPreview.first && !showReportedNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isBoostedNote,
nav,
onClick = { showHiddenNote = true }
onClick = { showReportedNote = true }
)
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, nav = nav)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
NormalNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
isAcceptableAndCanPreview.second,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
val scope = rememberCoroutineScope()
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NormalNote(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
canPreview: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
LaunchedEffect(key1 = routeForLastRead) {
scope.launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it)
val noteEvent = remember { baseNote.event }
val channelHex = remember { baseNote.channelHex() }
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
var popupExpanded by remember { mutableStateOf(false) }
val newIsNew = createdAt > lastTime
if (newIsNew != isNew) {
isNew = newIsNew
}
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) {
ChannelHeader(channelHex = channelHex, account = account, nav = nav)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = baseNote)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = routeForLastRead) {
scope.launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it)
val createdAt = baseNote.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
val newIsNew = createdAt > lastTime
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
}
}
val primaryColor = MaterialTheme.colors.primary.copy(0.12f)
val defaultBackgroundColor = MaterialTheme.colors.background
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember(isNew, parentBackgroundColor) {
if (isNew) {
if (parentBackgroundColor != null) {
primaryColor.compositeOver(parentBackgroundColor)
} else {
primaryColor.compositeOver(defaultBackgroundColor)
}
val backgroundColor = remember(isNew, parentBackgroundColor) {
if (isNew) {
if (parentBackgroundColor != null) {
primaryColor.compositeOver(parentBackgroundColor)
} else {
parentBackgroundColor ?: defaultBackgroundColor
primaryColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor ?: defaultBackgroundColor
}
}
val columnModifier = remember(backgroundColor) {
modifier
.combinedClickable(
onClick = {
scope.launch {
routeFor(note, loggedIn)?.let {
nav(it)
}
val columnModifier = remember(backgroundColor) {
modifier
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, loggedIn)?.let {
nav(it)
}
},
onLongClick = { popupExpanded = true }
)
.background(backgroundColor)
}
}
},
onLongClick = { popupExpanded = true }
)
.background(backgroundColor)
}
Column(modifier = columnModifier) {
Row(
Column(modifier = columnModifier) {
Row(
modifier = remember {
Modifier
.padding(
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp
)
}
) {
if (!isBoostedNote && !isQuotedNote) {
DrawAuthorImages(baseNote, loggedIn, nav)
}
Column(
modifier = remember {
Modifier
.padding(
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp
)
.padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp)
}
) {
if (!isBoostedNote && !isQuotedNote) {
DrawAuthorImages(baseNote, loggedIn, nav)
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
account = account,
accountViewModel = accountViewModel,
nav = nav
)
if (noteEvent !is RepostEvent && !makeItShort && !isQuotedNote) {
SecondUserInfoRow(
baseNote,
account,
nav
)
}
Column(
modifier = remember {
Modifier
.padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp)
}
) {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
account = account,
accountViewModel = accountViewModel,
nav = nav
Spacer(modifier = Modifier.height(2.dp))
if (!makeItShort) {
ReplyRow(
baseNote,
unPackReply,
backgroundColor,
account,
accountViewModel,
nav
)
}
if (noteEvent !is RepostEvent && !makeItShort && !isQuotedNote) {
SecondUserInfoRow(
note,
account,
nav
)
when (noteEvent) {
is ReactionEvent -> {
RenderReaction(baseNote, backgroundColor, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(2.dp))
is RepostEvent -> {
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
}
if (!makeItShort) {
ReplyRow(
note,
unPackReply,
is ReportEvent -> {
RenderReport(baseNote)
}
is LongTextNoteEvent -> {
RenderLongFormContent(baseNote, loggedIn, accountViewModel, nav)
}
is BadgeAwardEvent -> {
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
}
is PeopleListEvent -> {
RenderPeopleList(baseNote, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(baseNote, loggedIn, accountViewModel, nav)
}
is PinListEvent -> {
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
}
is PrivateDmEvent -> {
RenderPrivateMessage(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is HighlightEvent -> {
RenderHighlight(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is PollNoteEvent -> {
RenderPoll(
baseNote,
makeItShort,
canPreview,
backgroundColor,
account,
accountViewModel,
nav
)
}
when (noteEvent) {
is ReactionEvent -> {
RenderReaction(note, backgroundColor, accountViewModel, nav)
}
is RepostEvent -> {
RenderRepost(note, backgroundColor, accountViewModel, nav)
}
is ReportEvent -> {
RenderReport(note)
}
is LongTextNoteEvent -> {
RenderLongFormContent(note, loggedIn, accountViewModel, nav)
}
is BadgeAwardEvent -> {
RenderBadgeAward(note, backgroundColor, accountViewModel, nav)
}
is PeopleListEvent -> {
RenderPeopleList(noteState, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(note, loggedIn, accountViewModel, nav)
}
is PinListEvent -> {
RenderPinListEvent(noteState, backgroundColor, accountViewModel, nav)
}
is PrivateDmEvent -> {
RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, nav)
}
is HighlightEvent -> {
RenderHighlight(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, nav)
}
is PollNoteEvent -> {
RenderPoll(
note,
makeItShort,
isAcceptableAndCanPreview.second,
backgroundColor,
accountViewModel,
nav
)
}
else -> {
RenderTextEvent(
note,
makeItShort,
isAcceptableAndCanPreview.second,
backgroundColor,
accountViewModel,
nav
)
}
else -> {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
NoteQuickActionMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -403,20 +442,7 @@ fun routeFor(note: Note, loggedIn: User): String? {
return "Channel/${it.idHex}"
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == loggedIn) {
userToComposeOn = replyAuthorBase
}
}
return "Room/${userToComposeOn.pubkeyHex}"
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
} else {
return "Note/${note.idHex}"
}
@@ -581,14 +607,15 @@ private fun RenderPrivateMessage(
val noteEvent = note.event as? PrivateDmEvent ?: return
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
val tags = remember(note.event?.id()) { note.event?.tags() }
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
if (withMe) {
val eventContent = remember { accountViewModel.decrypt(note) }
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val tags = remember(note.event?.id()) { note.event?.tags() }
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
Text(
@@ -628,7 +655,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
emptyList(),
backgroundColor,
accountViewModel,
nav
@@ -647,16 +674,14 @@ private fun RenderPrivateMessage(
@Composable
fun RenderPeopleList(
noteState: NoteState?,
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
DisplayPeopleList(noteState, backgroundColor, accountViewModel, nav)
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
noteState?.note?.let {
ReactionsRow(it, accountViewModel, nav)
}
ReactionsRow(baseNote, accountViewModel, nav)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -666,13 +691,12 @@ fun RenderPeopleList(
@Composable
fun DisplayPeopleList(
noteState: NoteState?,
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val note = remember(noteState) { noteState?.note } ?: return
val noteEvent = note.event as? PeopleListEvent ?: return
val noteEvent = baseNote.event as? PeopleListEvent ?: return
var members by remember { mutableStateOf<List<User>>(listOf()) }
@@ -698,7 +722,7 @@ fun DisplayPeopleList(
textAlign = TextAlign.Center
)
LaunchedEffect(key1 = noteState) {
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
@@ -875,16 +899,16 @@ private fun RenderRepost(
@Composable
private fun RenderPinListEvent(
noteState: NoteState?,
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = noteState?.note?.event as? PinListEvent ?: return
val noteEvent = baseNote.event as? PinListEvent ?: return
PinListHeader(noteState, backgroundColor, accountViewModel, nav)
PinListHeader(baseNote, backgroundColor, accountViewModel, nav)
ReactionsRow(noteState?.note, accountViewModel, nav)
ReactionsRow(baseNote, accountViewModel, nav)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -894,15 +918,14 @@ private fun RenderPinListEvent(
@Composable
fun PinListHeader(
noteState: NoteState?,
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val note = remember(noteState) { noteState?.note } ?: return
val noteEvent = note.event as? PinListEvent ?: return
val noteEvent = baseNote.event as? PinListEvent ?: return
var pins by remember { mutableStateOf<List<String>>(noteEvent.pins()) }
var pins by remember { mutableStateOf(noteEvent.pins()) }
var expanded by remember {
mutableStateOf(false)
@@ -1754,7 +1777,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, loggedIn: User, nav
modifier = Modifier.padding(10.dp)
) {
cover?.let { cover ->
VideoView(
LoadThumbAndThenVideoView(
videoUri = media,
description = noteEvent.subject(),
thumbUri = cover

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
@@ -56,6 +58,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -68,9 +71,6 @@ import kotlin.math.roundToInt
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember {
@@ -82,17 +82,17 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (Strin
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, nav)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, nav)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
@@ -123,17 +123,6 @@ fun ReplyReaction(
iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet()
val isWriteable = remember { accountViewModel.isWriteable() }
val replyCount by remember(repliesState) {
derivedStateOf {
showCount(replies.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -148,7 +137,7 @@ fun ReplyReaction(
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (accountViewModel.isWriteable()) {
onPress()
} else {
scope.launch {
@@ -170,14 +159,26 @@ fun ReplyReaction(
}
if (showCounter) {
Text(
" $replyCount",
fontSize = 14.sp,
color = grayTint
)
ReplyCounter(baseNote, grayTint)
}
}
@Composable
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.observeAsState()
val replyCount by remember(repliesState) {
derivedStateOf {
" " + showCount(repliesState?.note?.replies?.size)
}
}
Text(
text = replyCount,
fontSize = 14.sp,
color = textColor
)
}
@Composable
fun BoostReaction(
baseNote: Note,
@@ -186,29 +187,6 @@ fun BoostReaction(
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = remember(boostsState) { boostsState?.note } ?: return
val hasBoosted by remember(boostsState) {
derivedStateOf {
accountViewModel.hasBoosted(baseNote)
}
}
val wasBoostedByLoggedIn by remember(boostsState) {
derivedStateOf {
boostedNote.isBoostedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val boostCount by remember(boostsState) {
derivedStateOf {
showCount(boostedNote.boosts.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -218,15 +196,11 @@ fun BoostReaction(
Modifier.size(iconSize)
}
val iconModifier = remember {
Modifier.size(iconSize)
}
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (hasBoosted) {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
accountViewModel.deleteBoostsTo(baseNote)
} else {
wantsToBoost = true
@@ -256,16 +230,45 @@ fun BoostReaction(
)
}
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = iconModifier,
tint = if (wasBoostedByLoggedIn) Color.Unspecified else grayTint
)
BoostIcon(baseNote, iconSize, grayTint, accountViewModel.userProfile())
}
BoostText(baseNote, grayTint)
}
@Composable
fun BoostIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
val boostsState by baseNote.live().boosts.observeAsState()
val iconTint by remember(boostsState) {
derivedStateOf {
if (boostsState?.note?.isBoostedBy(loggedIn) == true) Color.Unspecified else grayTint
}
}
val iconModifier = remember {
Modifier.size(iconSize)
}
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = iconModifier,
tint = iconTint
)
}
@Composable
fun BoostText(baseNote: Note, grayTint: Color) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostCount by remember(boostsState) {
derivedStateOf {
" " + showCount(boostsState?.note?.boosts?.size)
}
}
Text(
" $boostCount",
boostCount,
fontSize = 14.sp,
color = grayTint
)
@@ -279,29 +282,6 @@ fun LikeReaction(
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return
val hasReacted by remember(reactionsState) {
derivedStateOf {
accountViewModel.hasReactedTo(baseNote)
}
}
val wasReactedByLoggedIn by remember(reactionsState) {
derivedStateOf {
reactedNote.isReactedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val reactionCount by remember(reactionsState) {
derivedStateOf {
showCount(reactedNote.reactions.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -309,15 +289,11 @@ fun LikeReaction(
Modifier.size(iconSize)
}
val iconModifier = remember {
Modifier.size(heartSize)
}
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (hasReacted) {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
@@ -333,25 +309,55 @@ fun LikeReaction(
}
}
) {
if (wasReactedByLoggedIn) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
LikeIcon(baseNote, heartSize, grayTint, accountViewModel.userProfile())
}
LikeText(baseNote, grayTint)
}
@Composable
fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
val reactionsState by baseNote.live().reactions.observeAsState()
val wasReactedByLoggedIn by remember(reactionsState) {
derivedStateOf {
reactionsState?.note?.isReactedBy(loggedIn) == true
}
}
val iconModifier = remember {
Modifier.size(iconSize)
}
if (wasReactedByLoggedIn) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
}
}
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactionsCount by remember(reactionsState) {
derivedStateOf {
" " + showCount(reactionsState?.note?.reactions?.size)
}
}
Text(
" $reactionCount",
reactionsCount,
fontSize = 14.sp,
color = grayTint
)
@@ -363,16 +369,12 @@ fun ZapReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = remember(zapsState) { zapsState?.note } ?: return
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
@@ -382,32 +384,6 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmountTxt by remember { mutableStateOf<String>("") }
LaunchedEffect(key1 = zapsState) {
scope.launch(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
}
}
val newZapAmount = showAmount(account.calculateZappedAmount(zappedNote))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
}
if (wasZappedByLoggedInUser) {
if (abs(zappingProgress - 1) < 0.001) {
zappingProgress = 1f
}
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
@@ -498,6 +474,7 @@ fun ZapReaction(
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
}
@@ -506,28 +483,93 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
if (wasZappedByLoggedInUser) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(iconSize),
tint = BitcoinOrange
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(Modifier.width(3.dp))
val animatedProgress = animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
).value
CircularProgressIndicator(
progress = animatedProgress,
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
} else {
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(iconSize),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
ZapIcon(
baseNote,
iconSize,
grayTint,
accountViewModel
)
}
}
ZapAmountText(baseNote, grayTint, accountViewModel)
}
@Composable
private fun ZapIcon(
baseNote: Note,
iconSize: Dp,
grayTint: Color,
accountViewModel: AccountViewModel
) {
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
val zapsState by baseNote.live().zaps.observeAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapsState) {
scope.launch(Dispatchers.IO) {
zapsState?.note?.let {
if (!wasZappedByLoggedInUser) {
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(it)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
}
}
}
}
}
if (wasZappedByLoggedInUser) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
}
}
@Composable
private fun ZapAmountText(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = remember(zapsState) { zapsState?.note } ?: return
val scope = rememberCoroutineScope()
var zapAmountTxt by remember { mutableStateOf("") }
LaunchedEffect(key1 = zapsState) {
scope.launch(Dispatchers.IO) {
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(zappedNote))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
}
}
}
@@ -535,8 +577,7 @@ fun ZapReaction(
Text(
zapAmountTxt,
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
color = grayTint
)
}

View File

@@ -19,14 +19,14 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, nav: (String) -> Unit) {
var dupMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
}
}
@@ -119,7 +119,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<String>, channe
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
sortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()

View File

@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -63,7 +64,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -51,7 +52,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -21,7 +22,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes
import java.util.regex.Pattern
class AccountStateViewModel() : ViewModel() {
class AccountStateViewModel(val context: Context) : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
@@ -85,7 +86,7 @@ class AccountStateViewModel() : ViewModel() {
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
ServiceManager.start(account, context)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)

View File

@@ -290,6 +290,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
fun <T> equalImmutableLists(list1: ImmutableList<T>, list2: ImmutableList<T>): Boolean {
if (list1 === list2) return true
if (list1.size != list2.size) return false
for (i in 0 until list1.size) {
if (list1[i] !== list2[i]) {

View File

@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import kotlinx.collections.immutable.ImmutableList
sealed class FeedState {
object Loading : FeedState()
class Loaded(val feed: MutableState<List<Note>>) : FeedState()
class Loaded(val feed: MutableState<ImmutableList<Note>>) : FeedState()
object Empty : FeedState()
class FeedError(val errorMessage: String) : FeedState()
}

View File

@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -55,8 +57,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun newListFromDataSource(): List<Note> {
return localFilter.loadTop()
fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList()
}
private fun refresh() {
@@ -71,8 +73,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
if (!equalImmutableLists(notes, oldNotesState.feed.value)) {
updateFeed(notes)
}
} else {
@@ -80,7 +81,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private fun updateFeed(notes: List<Note>) {
private fun updateFeed(notes: ImmutableList<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = _feedContent.value
@@ -97,10 +98,20 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
if (newList !== oldNotesState.feed.value) {
updateFeed(newList)
if (localFilter is AdditiveFeedFilter) {
if (oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {
updateFeed(newList)
}
} else if (oldNotesState is FeedState.Empty) {
val newList = localFilter.updateListWith(emptyList(), newItems.toSet()).toImmutableList()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
} else {
// Refresh Everything
refreshSuspended()
}
} else {
// Refresh Everything
@@ -130,7 +141,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
if (localFilter is AdditiveFeedFilter &&
(_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty)
) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything

View File

@@ -78,6 +78,7 @@ 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.theme.newItemBackgroundColor
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterialApi::class)
@@ -151,7 +152,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
if (item.idHex == noteId) MaterialTheme.colors.primary.copy(alpha = 0.52f) else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
parentBackgroundColor = if (item.idHex == noteId) MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background) else null,
parentBackgroundColor = if (item.idHex == noteId) MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background) else null,
isBoostedNote = false,
unPackReply = false,
accountViewModel = accountViewModel,
@@ -306,7 +307,7 @@ fun NoteMaster(
if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 12.dp)) {
Column {
noteEvent.image()?.let {
AsyncImage(
@@ -336,7 +337,8 @@ fun NoteMaster(
text = it,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.padding(top = 10.dp),
color = Color.Gray
)
}
}
@@ -353,11 +355,11 @@ fun NoteMaster(
) {
Column() {
if (noteEvent is PeopleListEvent) {
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, nav)
DisplayPeopleList(baseNote, MaterialTheme.colors.background, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, note, account.userProfile(), nav)
} else if (noteEvent is PinListEvent) {
PinListHeader(noteState, MaterialTheme.colors.background, accountViewModel, nav)
PinListHeader(baseNote, MaterialTheme.colors.background, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),

View File

@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.Locale
@Immutable
@@ -66,6 +67,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
}
fun calculateZapAmount(zappedNote: Note): BigDecimal {
return account.calculateZappedAmount(zappedNote)
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
@@ -105,7 +110,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
?: "Error parsing error message"
)
} else {
onProgress(0.99f)
onProgress(1f)
}
}
)

View File

@@ -40,6 +40,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
@@ -63,6 +64,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
@@ -77,6 +79,8 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ChannelScreen(
@@ -158,7 +162,12 @@ fun ChannelScreen(
Spacer(modifier = Modifier.height(10.dp))
Row(Modifier.padding(horizontal = 10.dp).animateContentSize(), verticalAlignment = Alignment.CenterVertically) {
Row(
Modifier
.padding(horizontal = 10.dp)
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
) {
val replyingNote = replyTo.value
if (replyingNote != null) {
Column(Modifier.weight(1f)) {
@@ -182,7 +191,9 @@ fun ChannelScreen(
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(30.dp),
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -192,7 +203,9 @@ fun ChannelScreen(
// LAST ROW
Row(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth(),
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -246,10 +259,26 @@ fun ChannelScreen(
}
}
@Composable
fun ChannelHeader(channelHex: String, account: Account, nav: (String) -> Unit) {
var baseChannel by remember { mutableStateOf<Channel?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = channelHex) {
scope.launch(Dispatchers.IO) {
baseChannel = LocalCache.checkGetOrCreateChannel(channelHex)
}
}
baseChannel?.let {
ChannelHeader(it, account, nav)
}
}
@Composable
fun ChannelHeader(baseChannel: Channel, account: Account, nav: (String) -> Unit) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val channel = remember(channelState) { channelState?.channel } ?: return
val context = LocalContext.current.applicationContext

View File

@@ -123,7 +123,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV
}
is AccountState.LoggedIn -> {
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
NewNoteButton(state.account, accountViewModel, nav)
NewNoteButton(accountViewModel, nav)
}
if (currentRoute(navController) == Route.Message.base) {
ChannelFabColumn(state.account, nav)

View File

@@ -439,20 +439,39 @@ private fun ProfileActions(
coroutineScope: CoroutineScope
) {
val accountUserState by account.userProfile().live().follows.observeAsState()
val accountLocalUserState by account.live.observeAsState()
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
val isHidden by remember(accountUserState, accountLocalUserState) {
derivedStateOf {
account.isHidden(baseUser)
}
}
val isLoggedInFollowingUser by remember(accountUserState, accountLocalUserState) {
derivedStateOf {
accountUser.isFollowingCached(baseUser)
}
}
val isUserFollowingLoggedIn by remember(accountUserState, accountLocalUserState) {
derivedStateOf {
baseUser.isFollowingCached(accountUser)
}
}
if (accountUser == baseUser) {
EditButton(account)
}
if (account.isHidden(baseUser)) {
if (isHidden) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (accountUser.isFollowingCached(baseUser)) {
} else if (isLoggedInFollowingUser) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
if (baseUser.isFollowingCached(accountUser)) {
if (isUserFollowingLoggedIn) {
FollowButton(
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow_back

View File

@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -198,7 +199,7 @@ fun FeedView(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
feed: MutableState<List<Note>>,
feed: MutableState<ImmutableList<Note>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String? = null,
@@ -325,9 +326,6 @@ private fun RelayBadges(baseNote: Note) {
@Composable
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -337,11 +335,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, nav)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, nav)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
@@ -34,6 +35,10 @@ private val LightColorPalette = lightColors(
*/
)
val Colors.newItemBackgroundColor: Color
@Composable
get() = if (isLight) primary.copy(0.05f) else primary.copy(0.12f)
@Composable
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {

View File

@@ -0,0 +1,390 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">Nasměrujte na QR kód</string>
<string name="show_qr">Zobrazit QR kód</string>
<string name="profile_image">Profilový obrázek</string>
<string name="scan_qr">Skenovat QR kód</string>
<string name="show_anyway">Zobrazit přesto</string>
<string name="post_was_flagged_as_inappropriate_by">Příspěvek byl označen jako nevhodný uživatelem</string>
<string name="post_not_found">Příspěvek nenalezen</string>
<string name="channel_image">Obrázek kanálu</string>
<string name="referenced_event_not_found">Odkazovaná událost nebyla nalezena</string>
<string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string>
<string name="group_picture">Obrázek skupiny</string>
<string name="explicit_content">Explicitní obsah</string>
<string name="spam">Spam</string>
<string name="impersonation">Zneužívání identity</string>
<string name="illegal_behavior">Nezákonné jednání</string>
<string name="unknown">Neznámý</string>
<string name="relay_icon">Ikona předávání</string>
<string name="unknown_author">Neznámý autor</string>
<string name="copy_text">Kopírovat text</string>
<string name="copy_user_pubkey">Kopírovat ID autora</string>
<string name="copy_note_id">Kopírovat ID poznámky</string>
<string name="broadcast">Vysílání</string>
<string name="request_deletion">Požadovat smazání</string>
<string name="block_hide_user"><![CDATA[Blokovat a skrýt uživatele]]></string>
<string name="report_spam_scam">Nahlásit spam / podvod</string>
<string name="report_impersonation">Nahlásit zneužívání identity</string>
<string name="report_explicit_content">Nahlásit explicitní obsah</string>
<string name="report_illegal_behaviour">Nahlásit nezákonné jednání</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Pro odpověď se přihlaste s privátním klíčem</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Pro zvýšení příspěvků se přihlaste s privátním klíčem</string>
<string name="login_with_a_private_key_to_like_posts">Přihlásit se pomocí soukromého klíče pro označení příspěvků jako líbivé</string>
<string name="no_zap_amount_setup_long_press_to_change">Není nastaveno množství Zap. Dlouhým stisknutím změňte</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Přihlaste se pomocí soukromého klíče, abyste mohli odesílat Zapy</string>
<string name="zaps">Zapy</string>
<string name="view_count">Počet zobrazení</string>
<string name="boost">Boost</string>
<string name="boosted">boostováno</string>
<string name="quote">Citovat</string>
<string name="new_amount_in_sats">Nová částka v sats</string>
<string name="add">Přidat</string>
<string name="replying_to">"odpovídá na "</string>
<string name="and">"a "</string>
<string name="in_channel">"v kanálu "</string>
<string name="profile_banner">Banner profilu</string>
<string name="following">"Sleduje"</string>
<string name="followers">"Sledující"</string>
<string name="profile">Profil</string>
<string name="security_filters">Bezpečnostní filtry</string>
<string name="log_out">Odhlásit</string>
<string name="show_more">Zobrazit více</string>
<string name="lightning_invoice">Blesková faktura</string>
<string name="pay">Zaplatit</string>
<string name="lightning_tips">Bleskové tipy</string>
<string name="note_to_receiver">Poznámka pro příjemce</string>
<string name="thank_you_so_much">Děkuji moc!</string>
<string name="amount_in_sats">Částka v sats</string>
<string name="send_sats">Poslat sats</string>
<string name="error_parsing_preview_for">"Chyba při zpracování náhledu pro %1$s : %2$s"</string>
<string name="preview_card_image_for">"Náhled obrázku karty pro %1$s"</string>
<string name="new_channel">Nový kanál</string>
<string name="channel_name">Název kanálu</string>
<string name="my_awesome_group">Má skvělá skupina</string>
<string name="picture_url">URL obrázku</string>
<string name="description">Popis</string>
<string name="about_us">"O nás.. "</string>
<string name="what_s_on_your_mind">Co vás napadá?</string>
<string name="post">Příspěvek</string>
<string name="save">Uložit</string>
<string name="create">Vytvořit</string>
<string name="cancel">Zrušit</string>
<string name="failed_to_upload_the_image">Selhalo nahrávání obrázku</string>
<string name="relay_address">Adresa relé</string>
<string name="posts">Příspěvky</string>
<string name="bytes">Byty</string>
<string name="errors">Chyby</string>
<string name="home_feed">Domovský kanál</string>
<string name="private_message_feed">Kanál soukromých zpráv</string>
<string name="public_chat_feed">Kanál veřejného chatu</string>
<string name="global_feed"> Globální kanál</string>
<string name="search_feed">Kanál vyhledávání</string>
<string name="add_a_relay">Přidat přeposílání</string>
<string name="display_name">Zobrazované jméno</string>
<string name="my_display_name">Moje zobrazované jméno</string>
<string name="username">Uživatelské jméno</string>
<string name="my_username">Moje uživatelské jméno</string>
<string name="about_me">O mně</string>
<string name="avatar_url">URL avatara</string>
<string name="banner_url">URL banneru</string>
<string name="website_url">URL webové stránky</string>
<string name="ln_address">LN adresa</string>
<string name="ln_url_outdated">LN URL (zastaralé)</string>
<string name="image_saved_to_the_gallery">Obrázek uložen do galerie</string>
<string name="failed_to_save_the_image">Chyba při ukládání obrázku</string>
<string name="upload_image">Nahrát obrázek</string>
<string name="uploading">Nahrávání…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string>
<string name="reply_here">"Odpověď zde.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Zkopíruje ID poznámky do schránky pro sdílení</string>
<string name="copy_channel_id_note_to_the_clipboard">Zkopírovat ID kanálu (poznámka) do schránky</string>
<string name="edits_the_channel_metadata">Upravit metadata kanálu</string>
<string name="join">Připojit se</string>
<string name="known">Známé</string>
<string name="new_requests">Nové žádosti</string>
<string name="blocked_users">Blokovaní uživatelé</string>
<string name="new_threads">Nové vlákna</string>
<string name="conversations">Konverzace</string>
<string name="notes">Poznámky</string>
<string name="replies">Odpovědi</string>
<string name="follows">"Sleduje"</string>
<string name="reports">"Zprávy"</string>
<string name="more_options">Více možností</string>
<string name="relays">" Přeposílání"</string>
<string name="website">Webová stránka</string>
<string name="lightning_address">Lightning adresa</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Zkopíruje Nsec ID (vaše heslo) do schránky pro zálohování</string>
<string name="copy_private_key_to_the_clipboard">Zkopírovat tajný klíč do schránky</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Zkopíruje veřejný klíč do schránky pro sdílení</string>
<string name="copy_public_key_npub_to_the_clipboard">Zkopírovat veřejný klíč (NPub) do schránky</string>
<string name="send_a_direct_message">Poslat přímou zprávu</string>
<string name="edits_the_user_s_metadata">Upravit uživatelova metadata</string>
<string name="follow">Sledovat</string>
<string name="follow_back">Následovat zpět</string>
<string name="unblock">Odblokovat</string>
<string name="copy_user_id">Zkopírovat ID uživatele</string>
<string name="unblock_user">Odblokovat uživatele</string>
<string name="npub_hex_username">"npub, hex, uživatelské jméno"</string>
<string name="clear">Vymazat</string>
<string name="app_logo">Logo aplikace</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex privátní klíč</string>
<string name="show_password">Zobrazit heslo</string>
<string name="hide_password">Skrýt heslo</string>
<string name="invalid_key">Neplatný klíč</string>
<string name="i_accept_the">"Souhlasím s "</string>
<string name="terms_of_use">podmínkami použití</string>
<string name="acceptance_of_terms_is_required">Je vyžadováno přijetí podmínek</string>
<string name="key_is_required">Klíč je povinný</string>
<string name="login">Přihlásit se</string>
<string name="generate_a_new_key">Vygenerovat nový klíč</string>
<string name="loading_feed">Načítání zdroje</string>
<string name="error_loading_replies">"Chyba při načítání odpovědí: "</string>
<string name="try_again">Zkusit znovu</string>
<string name="feed_is_empty">Zdroj je prázdný.</string>
<string name="refresh">Obnovit</string>
<string name="created">vytvořeno</string>
<string name="with_description_of">s popisem</string>
<string name="and_picture">a obrázkem</string>
<string name="changed_chat_name_to">změnil název chatu na</string>
<string name="description_to">popis na</string>
<string name="and_picture_to">a obrázek na</string>
<string name="leave">Opustit</string>
<string name="unfollow">Přestat sledovat</string>
<string name="channel_created">Kanál vytvořen</string>
<string name="channel_information_changed_to">"Informace kanálu změněna na"</string>
<string name="public_chat">Veřejný chat</string>
<string name="posts_received">příspěvků přijato</string>
<string name="remove">Odebrat</string>
<string name="translations_auto">Automaticky</string>
<string name="translations_translated_from">přeloženo z</string>
<string name="translations_to">do</string>
<string name="translations_show_in_lang_first">Zobrazit nejprve v %1$s</string>
<string name="translations_always_translate_to_lang">Vždy překládat do %1$s</string>
<string name="translations_never_translate_from_lang">Nikdy nepřekládat z %1$s</string>
<string name="never">nikdy</string>
<string name="now">nyní</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Nahota</string>
<string name="profanity_hateful_speech">Neslušnost / Nenávistná řeč</string>
<string name="report_hateful_speech">Nahlásit nenávistnou řeč</string>
<string name="report_nudity_porn">Nahlásit nahotu / pornografii</string>
<string name="others">jiné</string>
<string name="mark_all_known_as_read">Označit všechny známé jako přečtené</string>
<string name="mark_all_new_as_read">Označit všechny nové jako přečtené</string>
<string name="mark_all_as_read">Označit všechny jako přečtené</string>
<string name="backup_keys">Zálohovat klíče</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## Tipy na zálohování a bezpečnost účtu
\n\nVáš účet je zabezpečen tajným klíčem. Klíč je dlouhý náhodný řetězec začínající **nsec1**. Každý, kdo má přístup k vašemu tajnému klíči, může publikovat obsah pod vaší identitou.
\n\n- **Nepoužívejte** svůj tajný klíč na webových stránkách nebo v softwaru, kterému nedůvěřujete.
\n- Vývojáři aplikace Amethyst vás **nikdy** nebudou žádat o váš tajný klíč.
\n- **Uchovávejte** si bezpečnou zálohu svého tajného klíče pro obnovení účtu. Doporučujeme používat správce hesel.
</string>
<string name="secret_key_copied_to_clipboard">Tajný klíč (nsec) zkopírován do schránky</string>
<string name="copy_my_secret_key">Zkopírovat můj tajný klíč</string>
<string name="biometric_authentication_failed">Autentizace se nezdařila</string>
<string name="biometric_error">Chyba</string>
<string name="badge_created_by">Vytvořeno uživatelem %1$s</string>
<string name="badge_award_image_for">Obrázek ocenění od %1$s</string>
<string name="new_badge_award_notif">Obdrželi jste nové ocenění</string>
<string name="award_granted_to">Ocenění uděleno uživateli</string>
<string name="copied_note_text_to_clipboard">Text poznámky zkopírován do schránky</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Uživatelovo ID (@npub) zkopírováno do schránky</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">ID poznámky (@note1) zkopírováno do schránky</string>
<string name="select_text_dialog_top">Vybrat text</string>
<string name="private_conversation_notification">"&lt;Nelze dešifrovat soukromou zprávu&gt;\n\nByl jste zmíněn v soukromé/zašifrované konverzaci mezi %1$s a %2$s."</string>
<string name="account_switch_add_account_dialog_title">Přidat nový účet</string>
<string name="drawer_accounts">Účty</string>
<string name="account_switch_select_account">Vybrat účet</string>
<string name="account_switch_add_account_btn">Přidat nový účet</string>
<string name="account_switch_active_account">Aktivní účet</string>
<string name="account_switch_has_private_key">Má soukromý klíč</string>
<string name="account_switch_pubkey_only">Pouze veřejný klíč, žádný soukromý klíč</string>
<string name="back">Zpět</string>
<string name="quick_action_select">Vybrat</string>
<string name="quick_action_share_browser_link">Sdílet odkaz prohlížeče</string>
<string name="quick_action_share">Sdílet</string>
<string name="quick_action_copy_user_id">ID autora</string>
<string name="quick_action_copy_note_id">ID poznámky</string>
<string name="quick_action_copy_text">Kopírovat text</string>
<string name="quick_action_delete">Smazat</string>
<string name="quick_action_unfollow">Přestat sledovat</string>
<string name="quick_action_follow">Sledovat</string>
<string name="quick_action_request_deletion_alert_title">Požadavek na smazání</string>
<string name="quick_action_request_deletion_alert_body">Amethyst požádá o smazání vaší poznámky ze spojek, ke kterým jste v současnosti připojeni. Není zaručeno, že vaše poznámka bude trvale smazána z těchto spojek nebo z dalších spojek, kde může být uložena.</string>
<string name="quick_action_block_dialog_btn">Blokovat</string>
<string name="quick_action_delete_dialog_btn">Smazat</string>
<string name="quick_action_block">Blokovat</string>
<string name="quick_action_report">Nahlásit</string>
<string name="quick_action_delete_button">Smazat</string>
<string name="quick_action_dont_show_again_button">Nepředvádět znovu</string>
<string name="report_dialog_spam">Spam nebo podvody</string>
<string name="report_dialog_profanity">Sprostota nebo nenávistné chování</string>
<string name="report_dialog_impersonation">Zákeřná zneužití</string>
<string name="report_dialog_nudity">Nahota nebo grafický obsah</string>
<string name="report_dialog_illegal">Nelegální chování</string>
<string name="report_dialog_blocking_a_user">Blokování uživatele skryje jejich obsah ve vaší aplikaci. Vaše poznámky jsou stále veřejně viditelné, včetně osob, které blokujete. Blokovaní uživatelé jsou uvedeni na obrazovce s bezpečnostními filtry.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokovat a skrýt uživatele]]></string>
<string name="report_dialog_report_btn">Nahlásit zneužití</string>
<string name="report_dialog_reminder_public">Všechny zveřejněné zprávy budou veřejně viditelné.</string>
<string name="report_dialog_additional_reason_placeholder">Volitelně poskytněte další kontext k vaší zprávě…</string>
<string name="report_dialog_additional_reason_label">Další kontext</string>
<string name="report_dialog_select_reason_label">Důvod</string>
<string name="report_dialog_select_reason_placeholder">Vyberte důvod…</string>
<string name="report_dialog_post_report_btn">Odeslat zprávu</string>
<string name="report_dialog_title">Blokovat a nahlásit</string>
<string name="block_only">Blokovat</string>
<string name="bookmarks">Záložky</string>
<string name="private_bookmarks">Soukromé záložky</string>
<string name="public_bookmarks">Veřejné záložky</string>
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
<string name="add_to_public_bookmarks">Přidat do veřejných záložek</string>
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
<string name="wallet_connect_service">Služba Wallet Connect</string>
<string name="wallet_connect_service_explainer">Autorizuje Nostr Secret k provedení platby zapsům, aniž byste opustili aplikaci. Uchovávejte tajemství v bezpečí a pokud je to možné, používejte soukromý přepojovač.</string>
<string name="wallet_connect_service_pubkey">Veřejný klíč Wallet Connect</string>
<string name="wallet_connect_service_relay">Relé Wallet Connect</string>
<string name="wallet_connect_service_secret">Tajemství Wallet Connect</string>
<string name="wallet_connect_service_show_secret">Zobrazit tajný klíč</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex soukromý klíč</string>
<string name="pledge_amount_in_sats">Částka záruky v sats</string>
<string name="post_poll">Zveřejnit hlasování</string>
<string name="poll_heading_required">Povinná pole:</string>
<string name="poll_zap_recipients">Příjemci zapsů</string>
<string name="poll_primary_description">Hlavní popis hlasování…</string>
<string name="poll_option_index">Možnost %s</string>
<string name="poll_option_description">Popis možnosti hlasování</string>
<string name="poll_heading_optional">Volitelná pole:</string>
<string name="poll_zap_value_min">Minimální zaps</string>
<string name="poll_zap_value_max">Maximální zaps</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Uzavřít po</string>
<string name="poll_closing_time_days">dnech</string>
<string name="poll_is_closed">Hlasování je uzavřeno pro nové hlasy</string>
<string name="poll_zap_amount">Částka zapsu</string>
<string name="one_vote_per_user_on_atomic_votes">U tohoto typu hlasování je povolen pouze jeden hlas na uživatele</string>
<string name="looking_for_event">"Hledá se událost %1$s"</string>
<string name="custom_zaps_add_a_message">Přidat veřejnou zprávu</string>
<string name="custom_zaps_add_a_message_private">Přidat soukromou zprávu</string>
<string name="custom_zaps_add_a_message_nonzap">Přidat fakturu</string>
<string name="custom_zaps_add_a_message_example">Děkuji za veškerou práci!</string>
<string name="lightning_create_and_add_invoice">Vytvořit a přidat</string>
<string name="poll_author_no_vote">Autoři hlasování nemohou hlasovat ve vlastních hlasováních.</string>
<string name="hash_verification_passed">Obrázek je stejný jako v původním příspěvku</string>
<string name="hash_verification_failed">Obrázek se změnil. Autor možná změnu nezaznamenal</string>
<string name="content_description_add_image">Přidat obrázek</string>
<string name="content_description_add_video">Přidat video</string>
<string name="content_description_add_document">Přidat dokument</string>
<string name="add_content">Přidat do zprávy</string>
<string name="content_description">Popis obsahu</string>
<string name="content_description_example">Modrá loď na bílé písčité pláži při západu slunce</string>
<string name="zap_type">Typ zapsu</string>
<string name="zap_type_explainer">Typ zapsu pro všechny možnosti</string>
<string name="zap_type_public">Veřejný</string>
<string name="zap_type_public_explainer">Všichni mohou vidět transakci a zprávu</string>
<string name="zap_type_private">Soukromý</string>
<string name="zap_type_private_explainer">Odesílatel a příjemce si mohou navzájem vidět a číst zprávu</string>
<string name="zap_type_anonymous">Anonymní</string>
<string name="zap_type_anonymous_explainer">Příjemce a veřejnost neví, kdo platbu poslal</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">Žádná stopa v Nostr, pouze v Lightning</string>
<string name="file_server">Souborový server</string>
<string name="zap_forward_lnAddress">LnAddress nebo @Uživatel</string>
<string name="upload_server_imgur">imgur.com důvěryhodný</string>
<string name="upload_server_imgur_explainer">Imgur může upravit soubor</string>
<string name="upload_server_nostrimg">nostrimg.com důvěryhodný</string>
<string name="upload_server_nostrimg_explainer">NostrImg může upravit soubor</string>
<string name="upload_server_nostrbuild">nostr.build důvěryhodný</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build může upravit soubor</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev důvěryhodný</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev může upravit soubor</string>
<string name="upload_server_imgur_nip94">Ověřitelný Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Ověří, zda Imgur upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrimg_nip94">Ověřitelný NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Ověří, zda NostrImg upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrbuild_nip94">Ověřitelný Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Ověří, zda Nostr.build upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrfilesdev_nip94">Ověřitelný Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Ověří, zda Nostrfiles.dev upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_relays_nip95">Vaše relé (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Soubory jsou hostovány vašimi relé. Nový NIP: zkontrolujte, zda je podporují</string>
<string name="connect_via_tor_short">Nastavení Tor/Orbot</string>
<string name="connect_via_tor">Připojit se přes váš Orbot</string>
<string name="do_you_really_want_to_disable_tor_title">Odpojit se od Orbotu/Toru?</string>
<string name="do_you_really_want_to_disable_tor_text">Vaše data budou okamžitě přenesena do běžné sítě</string>
<string name="yes">Ano</string>
<string name="no">Ne</string>
<string name="follow_list_selection">Seznam sledovaných</string>
<string name="follow_list_kind3follows">Všechna sledování</string>
<string name="follow_list_global">Globální</string>
<string name="connect_through_your_orbot_setup_markdown">
## Připojte se přes Tor s Orbotem
\n\n1. Nainstalujte [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Spusťte Orbot
\n3. V Orbotu zkontrolujte port pro Socks. Výchozí hodnota je 9050
\n4. Pokud je to nutné, změňte port v Orbotu
\n5. Nastavte port Socks v této obrazovce
\n6. Stiskněte tlačítko Aktivovat Tor
</string>
<string name="orbot_socks_port">Orbot Socks Port</string>
<string name="invalid_port_number">Neplatné číslo portu</string>
<string name="use_orbot">Použít Orbot</string>
<string name="disconnect_from_your_orbot_setup">Odpojit Tor/Orbot</string>
<string name="app_notification_dms_channel_name">Soukromé zprávy</string>
<string name="app_notification_dms_channel_description">Upozorní vás, když dorazí soukromá zpráva</string>
<string name="app_notification_zaps_channel_name">Přijaté zapsy</string>
<string name="app_notification_zaps_channel_description">Upozorní vás, když vás někdo zapsne</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
<string name="app_notification_zaps_channel_message_for">pro %1$s</string>
<string name="reply_notify">Upozornit: </string>
<string name="channel_list_join_conversation">Připojit se ke konverzaci</string>
<string name="channel_list_user_or_group_id">ID uživatele nebo skupiny</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent nebo hex</string>
<string name="channel_list_create_channel">Vytvořit</string>
<string name="channel_list_join_channel">Připojit se</string>
<string name="today">Dnes</string>
<string name="content_warning">Upozornění na obsah</string>
<string name="content_warning_explanation">Tento příspěvek obsahuje citlivý obsah, který může být pro některé lidi urážlivý nebo rušivý</string>
<string name="content_warning_hide_all_sensitive_content">Vždy skrýt citlivý obsah</string>
<string name="content_warning_show_all_sensitive_content">Vždy zobrazit citlivý obsah</string>
<string name="content_warning_see_warnings">Vždy zobrazovat upozornění na obsah</string>
</resources>

View File

@@ -3,11 +3,11 @@
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
<string name="point_to_the_qr_code"> به QRکد اشاره کنید</string>
<string name="show_qr">QR کد را نشان بده </string>
<string name="profile_image">تصویر پروفایل</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="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>
@@ -21,7 +21,7 @@
<string name="unknown_author">نویسنده ناشناس</string>
<string name="copy_text">کپی متن</string>
<string name="copy_user_pubkey">کپی شناسه نویسنده</string>
<string name="copy_note_id">کپی شناسه نوت</string>
<string name="copy_note_id">کپی شناسه یادداشت</string>
<string name="broadcast">انتشار</string>
<string name="request_deletion">درخواست حذف/string></string>
<string name="block_hide_user">بلاک/پنهان کردن کاربر</string>
@@ -30,14 +30,14 @@
<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="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="zaps">زپ</string>
<string name="view_count">دیدن تعداد</string>
<string name="boost">بازارسال</string>
<string name="boosted">بازارسال شده</string>
<string name="boost">بازنشر</string>
<string name="boosted">بازنشر شده</string>
<string name="quote">نقل قول</string>
<string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string>
<string name="add">افزودن</string>
@@ -51,7 +51,7 @@
<string name="security_filters">فیلترهای ایمنی</string>
<string name="log_out">خروج</string>
<string name="show_more">ادامه</string>
<string name="lightning_invoice">اینویس لایتنینگ</string>
<string name="lightning_invoice">صورتحساب لایتنینگ</string>
<string name="pay">پرداخت</string>
<string name="lightning_tips">انعام لایتنینگ</string>
<string name="note_to_receiver">یادداشت برای گیرنده</string>
@@ -73,7 +73,7 @@
<string name="cancel">لغو</string>
<string name="failed_to_upload_the_image">تصویر بارگذاری نشد</string>
<string name="relay_address">آدرس رله</string>
<string name="posts">نوت ها</string>
<string name="posts">یادداشت ها</string>
<string name="bytes">بایت</string>
<string name="errors">خطاها</string>
<string name="home_feed">خبرنامه اصلی</string>
@@ -98,7 +98,7 @@
<string name="uploading">...در حال بارگذاری</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
<string name="reply_here">"...اینجا پاسخ دهید "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">شناسه نوت را برای اشتراک گذاری در کلیپبورد ذخیره می کند </string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">شناسه یادداشت را برای اشتراک گذاری در کلیپبورد ذخیره می کند </string>
<string name="copy_channel_id_note_to_the_clipboard">ذخیره شناسه کانال در کلیپبورد</string>
<string name="edits_the_channel_metadata">متادیتای کانال را ذخیره می کند</string>
<string name="join">پیوستن</string>
@@ -107,7 +107,7 @@
<string name="blocked_users">کاربران بلاک شده</string>
<string name="new_threads">رشته جدید</string>
<string name="conversations">گفتگوها</string>
<string name="notes">نوت ها</string>
<string name="notes">یادداشت ها</string>
<string name="replies">پاسخ ها</string>
<string name="follows">"دنبال شوندگان"</string>
<string name="reports">"گزارش ها"</string>
@@ -130,8 +130,8 @@
<string name="clear">پاک کردن</string>
<string name="app_logo">لوگوی اپ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string>
<string name="show_password">نشان دادن پسورد</string>
<string name="hide_password">پنهان کردن پسورد</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>
@@ -139,8 +139,8 @@
<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="loading_feed">در حال بارگیری خبرنامه</string>
<string name="error_loading_replies">" :خطا در بارگیری پاسخ ها"</string>
<string name="try_again">دوباره تلاش کنید</string>
<string name="feed_is_empty">خبرنامه خالیست</string>
<string name="refresh">تازه سازی</string>
@@ -155,7 +155,7 @@
<string name="channel_created">کانال ساخته شد</string>
<string name="channel_information_changed_to">"اطلاعات کانال تغییر کرد به"</string>
<string name="public_chat">گفتگوی عمومی</string>
<string name="posts_received">نوت های دریافت شده</string>
<string name="posts_received">یادداشت های دریافت شده</string>
<string name="remove">بیرون کردن</string>
<string name="sats" translatable="false">ساتوشی</string>
<string name="translations_auto">خودکار</string>
@@ -189,15 +189,15 @@
</string>
<string name="secret_key_copied_to_clipboard">کلید خصوصی در کلیپبورد کپی شد</string>
<string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string>
<string name="biometric_authentication_failed">صحت سنجی انجام نشد</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_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">(@note1)شناسه نوت در کلیپبورد کپی شد</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">(@note1)شناسه یادداشت در کلیپبورد کپی شد</string>
<string name="select_text_dialog_top">انتخاب متن</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
@@ -217,16 +217,16 @@
<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_browser_link">اشتراک گذاری لینک مرورگر</string>
<string name="quick_action_share">اشتراک گذاری</string>
<string name="quick_action_copy_user_id">شناسه نویسنده</string>
<string name="quick_action_copy_note_id">شناسه نوت</string>
<string name="quick_action_copy_note_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">Request Deletion</string>
<string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که نوت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که نوت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string>
<string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string>
<string name="quick_action_block_dialog_btn">بلاک</string>
<string name="quick_action_delete_dialog_btn">حذف</string>
<string name="quick_action_block">بلاک</string>
@@ -238,7 +238,7 @@
<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_blocking_a_user">بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. یادداشت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Block & Hide User]]></string>
<string name="report_dialog_report_btn">گزارش سواستفاده</string>
<string name="report_dialog_reminder_public">تمام گزارش ها بطور عمومی دیده می شوند</string>
@@ -246,7 +246,7 @@
<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_post_report_btn">گزارش یادداشت</string>
<string name="report_dialog_title">بلاک و گزارش</string>
<string name="block_only">بلاک</string>
@@ -288,7 +288,7 @@
<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_nonzap">افزودن پیام صورتحساب</string>
<string name="custom_zaps_add_a_message_example">از زحمات شما متشکریم!</string>
@@ -296,7 +296,6 @@
<string name="poll_author_no_vote">سازندگان نظرسنجی نمی توانند در نظر سنجی خود رای دهند.</string>
<string name="poll_hashtag" translatable="false">#زپ_نظرسنجی</string>
<string name="hash_verification_passed">تصویر از زمان ارسال یکسان است</string>
<string name="hash_verification_failed">تصویر تغییر کرده است. ممکن است نویسنده تغییر را ندیده باشد.</string>
@@ -328,14 +327,18 @@
<string name="zap_forward_lnAddress">آدرس لایتنینگ یا @User</string>
<string name="upload_server_imgur">imgur.com - معتمد</string>
<string name="upload_server_imgur_explainer">Imgur میتواند فایل را بهینه کند</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_nostrimg_explainer">NostrImg می تواند فایل را بهینه کند</string>
<string name="upload_server_nostrbuild">nostr.build - معتمد</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</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_imgur_nip94">قابل بررسی Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">بررسی کنید که Imgur فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. </string>
@@ -345,13 +348,66 @@
<string name="upload_server_nostrbuild_nip94">Verifiable 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_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">
## از طریق Tor با Orbot متصل شوید
\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. دکمه Activate را بفشارید تا از Orbot به عنوان پراکسی استفده نمایید
</string>
<string name="orbot_socks_port">Orbot Socks Port</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_channel_id" translatable="false">DefaultChannelID</string>
<string name="app_notification_private_message" translatable="false">اعلان جدید</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
<string name="app_notification_dms_channel_name">پیام خصوصی</string>
<string name="app_notification_dms_channel_description">وقتی پیام خصوصی دریافت شود اعلان می کند</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</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 ساتوشی</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">شناسه گروه یا کاربر</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent or 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>
</resources>

View File

@@ -0,0 +1,121 @@
package com.vitorpamplona.amethyst.service
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.SpyK
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
class Nip05VerifierTest {
private val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private val ALL_LOWER_CASE_USER_NAME = "onetwo"
@SpyK
var nip05Verifier = Nip05Verifier()
@Before
fun setUp() = MockKAnnotations.init(this)
@Test
fun `test with matching case on user name`() {
// Set-up
val userNameToTest = ALL_UPPER_CASE_USER_NAME
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{\n" +
" \"names\": {\n" +
" \"$userNameToTest\": \"$expectedPubKey\" \n" +
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}
val nip05 = "$userNameToTest@domain.com"
var actualPubkeyHex = ""
// Execution
nip05Verifier.verifyNip05(
nip05,
onSuccess = {
actualPubkeyHex = it
},
onError = {
fail("Test failure")
}
)
// Verification
assertEquals(expectedPubKey, actualPubkeyHex)
}
@Test
fun `test with NOT matching case on user name`() {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{\n" +
" \"names\": {\n" +
" \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" +
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}
val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com"
var actualPubkeyHex = ""
// Execution
nip05Verifier.verifyNip05(
nip05,
onSuccess = {
actualPubkeyHex = it
},
onError = {
fail("Test failure")
}
)
// Verification
assertEquals(expectedPubKey, actualPubkeyHex)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `execute assemble url with invalid value returns null`() {
// given
val nip05address = "this@that@that.com"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
// then
assertNull(actualValue)
}
@Test
fun `execute assemble url with valid value returns nip05 url`() {
// given
val userName = "TheUser"
val domain = "domain.com"
val nip05address = "$userName@$domain"
val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
// then
assertEquals(expectedValue, actualValue)
}
}