mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-05 22:34:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
609faf245a | ||
|
|
bfd28c4b44 | ||
|
|
70a8488f12 | ||
|
|
cda6e4722c | ||
|
|
c4ecf85618 | ||
|
|
6ed1f9d369 | ||
|
|
aea765ec54 | ||
|
|
165c3bb852 | ||
|
|
2e9683491f | ||
|
|
eea8b99622 | ||
|
|
0db6a0d27b | ||
|
|
723e575b4b | ||
|
|
b182d409b6 | ||
|
|
043e37030e | ||
|
|
8d730040b8 | ||
|
|
794a300c74 | ||
|
|
91fd490ab7 | ||
|
|
0e1de03db8 | ||
|
|
7906a94ca0 | ||
|
|
6ce019a86a | ||
|
|
d8939779e8 | ||
|
|
313d87a58b | ||
|
|
057e61bfe1 | ||
|
|
c94c882c3a | ||
|
|
673f632563 | ||
|
|
1a56b2bf68 | ||
|
|
2e9964e0e8 | ||
|
|
b80eb32991 | ||
|
|
ad281c7607 | ||
|
|
3c1a918b2d | ||
|
|
53e6677558 | ||
|
|
b8deb34544 | ||
|
|
d9cffa87ef | ||
|
|
ae418f9d45 | ||
|
|
d10b4c6bde | ||
|
|
f1e516662c | ||
|
|
1bf8165641 | ||
|
|
807af3b613 | ||
|
|
e36ec0a0a3 | ||
|
|
3172b69271 | ||
|
|
81ee13bebc | ||
|
|
a6a8138c7f | ||
|
|
16142f61b8 | ||
|
|
7193228dd2 | ||
|
|
29b8f6916b | ||
|
|
a6519d57c6 | ||
|
|
2b2ee5bbfc | ||
|
|
62a6b7b7e0 | ||
|
|
5e0a1f9de1 | ||
|
|
571b1bc9cf | ||
|
|
a3e76225e6 | ||
|
|
4388ceb529 | ||
|
|
5d4fa0d732 | ||
|
|
ed6825de97 | ||
|
|
10f623b6b3 | ||
|
|
467e5c0e4a | ||
|
|
09d2e9593e | ||
|
|
cd20c0567b | ||
|
|
db15f5e2a6 | ||
|
|
18d5c7e3f4 | ||
|
|
d42ae7c9f7 | ||
|
|
2b741b62e8 | ||
|
|
4e1c63d36a | ||
|
|
95797171d7 | ||
|
|
395ef9695e | ||
|
|
ad1c7088c7 | ||
|
|
eda4569b64 | ||
|
|
fb5f1e738b | ||
|
|
e3cca6c691 | ||
|
|
ce2b8dfa11 | ||
|
|
b5faf6ffc5 | ||
|
|
4ee157f27f | ||
|
|
ad8c04b79a | ||
|
|
c0e6273954 | ||
|
|
aebade21a6 | ||
|
|
bda24c0101 | ||
|
|
3d0b461550 | ||
|
|
5900e6c49f | ||
|
|
f29fe3ced5 | ||
|
|
bf17cc8f03 | ||
|
|
a8d10c22d4 | ||
|
|
5492ebf43f | ||
|
|
d66a027270 |
+2
-2
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 371
|
||||
versionName "0.87.2"
|
||||
versionCode 376
|
||||
versionName "0.87.7"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"863124b6592359494396c22b917f9d251ad12ac31b09ea29e0265e3c088ce9f8\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
+139
-8
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -47,6 +48,136 @@ class UrlUserTagTransformationTest {
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
|
||||
}
|
||||
|
||||
fun debugCursor(
|
||||
original: String,
|
||||
transformedText: TransformedText,
|
||||
offset: Int,
|
||||
): String {
|
||||
val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset)
|
||||
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
|
||||
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
|
||||
return "$originalWithCursor $transformedWithCursor"
|
||||
}
|
||||
|
||||
fun debugCursorReverse(
|
||||
original: String,
|
||||
transformedText: TransformedText,
|
||||
offsetTransformed: Int,
|
||||
): String {
|
||||
val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed)
|
||||
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
|
||||
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
|
||||
return "$originalWithCursor $transformedWithCursor"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKeepTransformedIndexFullyInsideTransformedText() {
|
||||
val user =
|
||||
LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString(original),
|
||||
Color.Red,
|
||||
)
|
||||
|
||||
val expected = "@Vitor Pamplona"
|
||||
assertEquals(expected, transformedText.text.text)
|
||||
|
||||
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0))
|
||||
assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1))
|
||||
assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2))
|
||||
assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3))
|
||||
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4))
|
||||
assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5))
|
||||
assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6))
|
||||
assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7))
|
||||
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8))
|
||||
assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9))
|
||||
assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10))
|
||||
assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11))
|
||||
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12))
|
||||
assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13))
|
||||
assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14))
|
||||
assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15))
|
||||
assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16))
|
||||
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17))
|
||||
assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18))
|
||||
assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19))
|
||||
assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20))
|
||||
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64))
|
||||
|
||||
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0))
|
||||
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1))
|
||||
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2))
|
||||
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3))
|
||||
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4))
|
||||
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15))
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
|
||||
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user =
|
||||
@@ -70,11 +201,11 @@ class UrlUserTagTransformationTest {
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
|
||||
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
|
||||
@@ -106,12 +237,12 @@ class UrlUserTagTransformationTest {
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
|
||||
assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
|
||||
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
|
||||
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
|
||||
@@ -120,7 +251,7 @@ class UrlUserTagTransformationTest {
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
|
||||
|
||||
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
|
||||
assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
|
||||
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
|
||||
|
||||
+2
@@ -36,6 +36,7 @@ fun TranslatableRichTextViewer(
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) = ExpandableRichTextViewer(
|
||||
@@ -46,6 +47,7 @@ fun TranslatableRichTextViewer(
|
||||
tags,
|
||||
backgroundColor,
|
||||
id,
|
||||
callbackUri,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -53,7 +54,9 @@ class Amethyst : Application() {
|
||||
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
newCache.initFileCache(this)
|
||||
runBlocking {
|
||||
newCache.initFileCache(this@Amethyst)
|
||||
}
|
||||
newCache
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ object LocalPreferences {
|
||||
* deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) =
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
|
||||
withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
@@ -257,6 +258,7 @@ object LocalPreferences {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
@@ -267,7 +269,8 @@ object LocalPreferences {
|
||||
return savedAccounts()
|
||||
}
|
||||
|
||||
suspend fun saveToEncryptedStorage(account: Account) =
|
||||
suspend fun saveToEncryptedStorage(account: Account) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage")
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -340,11 +343,12 @@ object LocalPreferences {
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(account.pendingAttestations),
|
||||
Event.mapper.writeValueAsString(account.pendingAttestations.value),
|
||||
)
|
||||
}
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
|
||||
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
@@ -354,6 +358,7 @@ object LocalPreferences {
|
||||
sharedSettings: Settings,
|
||||
prefs: SharedPreferences = encryptedPreferences(),
|
||||
) {
|
||||
Log.d("LocalPreferences", "Saving to shared settings")
|
||||
with(prefs.edit()) {
|
||||
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
|
||||
apply()
|
||||
@@ -361,6 +366,7 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
Log.d("LocalPreferences", "Load shared settings")
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
|
||||
@@ -395,8 +401,10 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
|
||||
Log.d("LocalPreferences", "Load account from file")
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
@@ -604,7 +612,7 @@ object LocalPreferences {
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
hasDonatedInVersion = hasDonatedInVersion,
|
||||
pendingAttestations = pendingAttestations ?: emptyMap(),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
@@ -618,4 +626,5 @@ object LocalPreferences {
|
||||
return@with account
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
@@ -125,6 +126,7 @@ import kotlinx.coroutines.flow.flattenMerge
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -201,7 +203,7 @@ class Account(
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
|
||||
var hasDonatedInVersion: Set<String> = setOf<String>(),
|
||||
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
|
||||
var pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
|
||||
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
|
||||
) {
|
||||
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
|
||||
@@ -238,10 +240,16 @@ class Account(
|
||||
userProfile().flow().relays.stateFlow,
|
||||
) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
|
||||
val baseRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet()
|
||||
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet
|
||||
val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()
|
||||
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays() ?: emptySet()
|
||||
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
|
||||
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: Constants.defaultSearchRelaySet
|
||||
val nip65RelaySet =
|
||||
(nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()?.map {
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(
|
||||
RelayUrlFormatter.normalize(it.relayUrl),
|
||||
it.type,
|
||||
)
|
||||
}
|
||||
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
|
||||
|
||||
// ------
|
||||
// DMs
|
||||
@@ -250,7 +258,7 @@ class Account(
|
||||
var mappedRelaySet =
|
||||
baseRelaySet.map {
|
||||
if (newDMRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + FeedType.PRIVATE_DMS)
|
||||
RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
@@ -258,7 +266,7 @@ class Account(
|
||||
|
||||
newDMRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.PRIVATE_DMS))
|
||||
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +277,7 @@ class Account(
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (searchRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, it.write || false, it.activeTypes + FeedType.SEARCH)
|
||||
RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
@@ -277,7 +285,7 @@ class Account(
|
||||
|
||||
searchRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, false, setOf(FeedType.SEARCH))
|
||||
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, false, setOf(FeedType.SEARCH))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +296,7 @@ class Account(
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (privateOutboxRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
@@ -296,7 +304,7 @@ class Account(
|
||||
|
||||
privateOutboxRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +315,7 @@ class Account(
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (localRelayServers.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
@@ -315,7 +323,7 @@ class Account(
|
||||
|
||||
localRelayServers.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +337,7 @@ class Account(
|
||||
if (nip65setup != null) {
|
||||
val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
|
||||
|
||||
Relay(relay.url, true, relay.write || write, relay.activeTypes + setOf(FeedType.FOLLOWS, FeedType.GLOBAL, FeedType.PUBLIC_CHATS))
|
||||
RelaySetupInfo(relay.url, true, relay.write || write, relay.feedTypes + setOf(FeedType.FOLLOWS, FeedType.GLOBAL, FeedType.PUBLIC_CHATS))
|
||||
} else {
|
||||
relay
|
||||
}
|
||||
@@ -339,7 +347,7 @@ class Account(
|
||||
if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) {
|
||||
val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
|
||||
|
||||
mappedRelaySet = mappedRelaySet + Relay(newNip65Setup.relayUrl, true, write, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS))
|
||||
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newNip65Setup.relayUrl, true, write, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,10 +789,7 @@ class Account(
|
||||
note.event?.let { event ->
|
||||
LnZapRequestEvent.create(
|
||||
event,
|
||||
relays =
|
||||
getNIP65RelayList()?.readRelays()?.toSet()
|
||||
?: userProfile().latestContactList?.relays()?.keys?.ifEmpty { null }
|
||||
?: localRelays.map { it.url }.toSet(),
|
||||
relays = getReceivingRelays(),
|
||||
signer,
|
||||
pollOption,
|
||||
message,
|
||||
@@ -795,6 +800,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun getReceivingRelays(): Set<String> {
|
||||
return getNIP65RelayList()?.readRelays()?.toSet()
|
||||
?: userProfile().latestContactList?.relays()?.filter { it.value.read }?.keys?.ifEmpty { null }
|
||||
?: localRelays.filter { it.read }.map { it.url }.toSet()
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean {
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
@@ -1004,9 +1015,9 @@ class Account(
|
||||
}
|
||||
|
||||
suspend fun updateAttestations() {
|
||||
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
|
||||
Log.d("Pending Attestations", "Updating ${pendingAttestations.value.size} pending attestations")
|
||||
|
||||
pendingAttestations.toMap().forEach { pair ->
|
||||
pendingAttestations.value.forEach { pair ->
|
||||
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
|
||||
|
||||
if (pair.value != newAttestation) {
|
||||
@@ -1014,7 +1025,9 @@ class Account(
|
||||
LocalCache.justConsume(it, null)
|
||||
Client.send(it)
|
||||
|
||||
pendingAttestations = pendingAttestations - pair.key
|
||||
pendingAttestations.update {
|
||||
it - pair.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,7 +1035,7 @@ class Account(
|
||||
|
||||
fun hasPendingAttestations(note: Note): Boolean {
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
return pendingAttestations.get(id) != null
|
||||
return pendingAttestations.value[id] != null
|
||||
}
|
||||
|
||||
fun timestamp(note: Note) {
|
||||
@@ -1031,7 +1044,9 @@ class Account(
|
||||
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
|
||||
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, OtsEvent.stamp(id))
|
||||
}
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
@@ -1301,7 +1316,7 @@ class Account(
|
||||
fun consumeAndSendNip95(
|
||||
data: FileStorageEvent,
|
||||
signedEvent: FileStorageHeaderEvent,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
@@ -1327,7 +1342,7 @@ class Account(
|
||||
fun sendNip95(
|
||||
data: FileStorageEvent,
|
||||
signedEvent: FileStorageHeaderEvent,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
) {
|
||||
Client.send(data, relayList = relayList)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
@@ -1335,7 +1350,7 @@ class Account(
|
||||
|
||||
fun sendHeader(
|
||||
signedEvent: FileHeaderEvent,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
@@ -1379,7 +1394,7 @@ class Account(
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -1414,7 +1429,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
draftTag: String?,
|
||||
@@ -1487,7 +1502,7 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@@ -1576,7 +1591,7 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@@ -1644,7 +1659,7 @@ class Account(
|
||||
originalNote: Note,
|
||||
notify: HexKey?,
|
||||
summary: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1674,7 +1689,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@@ -2462,8 +2477,14 @@ class Account(
|
||||
dvmPublicKey: String,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
||||
) {
|
||||
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer) {
|
||||
Client.send(it)
|
||||
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) {
|
||||
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(dvmPublicKey))?.event as? AdvertisedRelayListEvent)?.readRelays()
|
||||
|
||||
if (relayList != null) {
|
||||
Client.sendPrivately(it, relayList)
|
||||
} else {
|
||||
Client.send(it)
|
||||
}
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
@@ -2584,35 +2605,37 @@ class Account(
|
||||
}
|
||||
|
||||
// Takes a User's relay list and adds the types of feeds they are active for.
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
fun activeRelays(): Array<RelaySetupInfo>? {
|
||||
val usersRelayList =
|
||||
userProfile().latestContactList?.relays()?.map {
|
||||
val url = RelayUrlFormatter.normalize(it.key)
|
||||
|
||||
val localFeedTypes =
|
||||
localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes
|
||||
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes
|
||||
?: Constants.defaultRelays
|
||||
.filter { defaultRelay -> defaultRelay.url == it.key }
|
||||
.filter { defaultRelay -> defaultRelay.url == url }
|
||||
.firstOrNull()
|
||||
?.feedTypes
|
||||
?: FeedType.values().toSet()
|
||||
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
|
||||
} ?: return null
|
||||
|
||||
return usersRelayList.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
return localRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray()
|
||||
fun convertLocalRelays(): Array<RelaySetupInfo> {
|
||||
return localRelays.map { RelaySetupInfo(RelayUrlFormatter.normalize(it.url), it.read, it.write, it.feedTypes) }.toTypedArray()
|
||||
}
|
||||
|
||||
fun activeGlobalRelays(): Array<String> {
|
||||
return connectToRelays.value
|
||||
.filter { it.activeTypes.contains(FeedType.GLOBAL) }
|
||||
.filter { it.feedTypes.contains(FeedType.GLOBAL) }
|
||||
.map { it.url }
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
fun activeWriteRelays(): List<Relay> {
|
||||
fun activeWriteRelays(): List<RelaySetupInfo> {
|
||||
return connectToRelays.value.filter { it.write }
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStats
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
@@ -69,10 +70,6 @@ class AntiSpamFilter {
|
||||
if (
|
||||
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
|
||||
) {
|
||||
Log.w(
|
||||
"Potential SPAM Message for sharing",
|
||||
"${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
|
||||
)
|
||||
Log.w(
|
||||
"Potential SPAM Message",
|
||||
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
|
||||
@@ -81,6 +78,10 @@ class AntiSpamFilter {
|
||||
// Log down offenders
|
||||
logOffender(hash, event)
|
||||
|
||||
if (relay != null) {
|
||||
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
|
||||
}
|
||||
|
||||
liveSpam.invalidateData()
|
||||
|
||||
return true
|
||||
@@ -100,7 +101,7 @@ class AntiSpamFilter {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
spammer.duplicatedMessages += event.id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -450,7 +450,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -474,6 +473,8 @@ object LocalCache {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
@@ -623,7 +624,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -648,7 +648,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -673,7 +672,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -711,7 +709,6 @@ object LocalCache {
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -746,7 +743,6 @@ object LocalCache {
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -827,7 +823,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1533,7 +1528,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1572,7 +1566,6 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,5 @@ data class RelaySetupInfo(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -31,11 +31,12 @@ object CachedRichTextParser {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags)
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
|
||||
richTextCache.put(content, newUrls)
|
||||
newUrls
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForUrl(url: String): OkHttpClient {
|
||||
// TODO: How to identify relays on the local network?
|
||||
val isLocalHost = url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")
|
||||
return if (isLocalHost) {
|
||||
getHttpClient(false)
|
||||
} else {
|
||||
getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
|
||||
return if (useProxy) {
|
||||
if (this.defaultHttpClient == null) {
|
||||
|
||||
@@ -112,9 +112,8 @@ class Nip11Retriever {
|
||||
try {
|
||||
val request: Request =
|
||||
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
|
||||
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
|
||||
|
||||
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
|
||||
HttpClientManager.getHttpClientForUrl(dirtyUrl)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -80,7 +80,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
|
||||
return TypedFilter(
|
||||
// Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
@@ -99,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
// Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.KIND),
|
||||
|
||||
@@ -92,19 +92,6 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
error: Error,
|
||||
subscriptionId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
// if (subscriptions.containsKey(subscriptionId)) {
|
||||
// Log.e(
|
||||
// this@NostrDataSource.javaClass.simpleName,
|
||||
// "Relay OnError ${relay.url}: ${error.message}"
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(
|
||||
type: Relay.StateType,
|
||||
relay: Relay,
|
||||
|
||||
@@ -120,6 +120,8 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
@@ -157,6 +159,8 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
|
||||
@@ -178,8 +178,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 10,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -245,7 +244,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
eventsToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
|
||||
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
@@ -254,7 +253,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
addressesToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
|
||||
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
@@ -44,7 +45,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
|
||||
authors = firstTimers,
|
||||
),
|
||||
),
|
||||
@@ -67,7 +68,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND),
|
||||
authors = groupIds,
|
||||
since = minEOSEs,
|
||||
),
|
||||
|
||||
@@ -28,10 +28,15 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
|
||||
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
@@ -40,8 +45,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
|
||||
var job: Job? = null
|
||||
|
||||
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
@@ -68,9 +71,9 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
filter =
|
||||
JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
limit = 200,
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
@@ -89,14 +92,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_MIME_TYPES,
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
@@ -117,14 +120,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_MIME_TYPES,
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
|
||||
+15
-7
@@ -86,6 +86,8 @@ class MultiPlayerPlaybackManager(
|
||||
applicationContext: Context,
|
||||
): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
// avoids saving positions for live streams otherwise caching goes crazy
|
||||
val mustCachePositions = !uri.contains(".m3u8", true)
|
||||
if (existingSession != null) return existingSession
|
||||
|
||||
val player =
|
||||
@@ -115,7 +117,9 @@ class MultiPlayerPlaybackManager(
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
player.setWakeMode(C.WAKE_MODE_NONE)
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
if (mustCachePositions) {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
@@ -125,20 +129,22 @@ class MultiPlayerPlaybackManager(
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
// only saves if it wqs playing
|
||||
if (abs(player.currentPosition) > 1) {
|
||||
if (mustCachePositions && abs(player.currentPosition) > 1) {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
if (mustCachePositions) {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// only saves if it wqs playing
|
||||
if (abs(player.currentPosition) > 1) {
|
||||
if (mustCachePositions && abs(player.currentPosition) > 1) {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +156,9 @@ class MultiPlayerPlaybackManager(
|
||||
newPosition: PositionInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
cachedPositions.add(uri, newPosition.positionMs)
|
||||
if (mustCachePositions && player.playbackState != STATE_IDLE) {
|
||||
cachedPositions.add(uri, newPosition.positionMs)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -34,41 +34,38 @@ import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
|
||||
@UnstableApi
|
||||
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
|
||||
/**
|
||||
* HLS LiveStreams cannot use cache.
|
||||
*/
|
||||
@UnstableApi
|
||||
class CustomMediaSourceFactory() : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
|
||||
@UnstableApi
|
||||
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
return this
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
return this
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun getSupportedTypes(): IntArray {
|
||||
return http.supportedTypes
|
||||
return nonCachingFactory.supportedTypes
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
return if (mediaItem.mediaId.startsWith("wss")) {
|
||||
wss.createMediaSource(mediaItem)
|
||||
} else {
|
||||
http.createMediaSource(mediaItem)
|
||||
if (mediaItem.mediaId.contains(".m3u8", true)) {
|
||||
return nonCachingFactory.createMediaSource(mediaItem)
|
||||
}
|
||||
return cachingFactory.createMediaSource(mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +78,7 @@ class PlaybackService : MediaSessionService() {
|
||||
fun newAllInOneDataSource(): MediaSource.Factory {
|
||||
// This might be needed for live kit.
|
||||
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
|
||||
return DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
|
||||
return CustomMediaSourceFactory()
|
||||
}
|
||||
|
||||
fun lazyDS(): MultiPlayerPlaybackManager {
|
||||
|
||||
@@ -27,6 +27,8 @@ import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
@@ -41,16 +43,17 @@ class VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
fun initFileCache(context: Context) {
|
||||
suspend fun initFileCache(context: Context) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
File(context.cacheDir, "exoplayer"),
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
File(context.cacheDir, "exoplayer"),
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// This method should be called when proxy setting changes.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
@@ -41,10 +42,10 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@Synchronized
|
||||
fun reconnect(
|
||||
relays: Array<Relay>?,
|
||||
relays: Array<RelaySetupInfo>?,
|
||||
onlyIfChanged: Boolean = false,
|
||||
) {
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.activeTypes.joinToString(",") { it.name } }}")
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}")
|
||||
checkNotInMainThread()
|
||||
|
||||
if (onlyIfChanged) {
|
||||
@@ -56,10 +57,11 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.loadRelays(newRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
this.relays = newRelays.toTypedArray()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -70,15 +72,16 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.loadRelays(newRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
this.relays = newRelays.toTypedArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<RelaySetupInfo>?): Boolean {
|
||||
if (relays.size != newRelayConfig?.size) return false
|
||||
|
||||
relays.forEach { oldRelayInfo ->
|
||||
@@ -142,7 +145,7 @@ object Client : RelayPool.Listener {
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onDone: (() -> Unit)? = null,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
@@ -165,7 +168,7 @@ object Client : RelayPool.Listener {
|
||||
checkNotInMainThread()
|
||||
|
||||
relayList.forEach { relayUrl ->
|
||||
RelayPool.getOrCreateRelay(relayUrl, setOf(FeedType.PRIVATE_DMS), { }) {
|
||||
RelayPool.getOrCreateRelay(relayUrl, emptySet(), { }) {
|
||||
it.sendOverride(signedEvent)
|
||||
}
|
||||
}
|
||||
@@ -194,19 +197,6 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onError(
|
||||
error: Error,
|
||||
subscriptionId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onRelayStateChange(
|
||||
type: Relay.StateType,
|
||||
@@ -284,13 +274,6 @@ object Client : RelayPool.Listener {
|
||||
afterEOSE: Boolean,
|
||||
) = Unit
|
||||
|
||||
/** A new or repeat message was received */
|
||||
open fun onError(
|
||||
error: Error,
|
||||
subscriptionId: String,
|
||||
relay: Relay,
|
||||
) = Unit
|
||||
|
||||
/** Connected to or disconnected from a relay */
|
||||
open fun onRelayStateChange(
|
||||
type: Relay.StateType,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
@@ -36,21 +37,26 @@ object Constants {
|
||||
val defaultRelays =
|
||||
arrayOf(
|
||||
// Free relays for only DMs, Chats and Follows due to the amount of spam
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypes),
|
||||
// Global
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
// Paid relays
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
|
||||
val defaultSearchRelaySet = setOf("wss://relay.nostr.band", "wss://nostr.wine", "wss://relay.noswhere.com")
|
||||
val defaultSearchRelaySet =
|
||||
setOf(
|
||||
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.wine"),
|
||||
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
@@ -59,38 +60,24 @@ class Relay(
|
||||
val write: Boolean = true,
|
||||
val activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
) {
|
||||
val brief = RelayBriefInfoCache.get(url)
|
||||
|
||||
companion object {
|
||||
// waits 3 minutes to reconnect once things fail
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient =
|
||||
if (url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")) {
|
||||
HttpClientManager.getHttpClient(false)
|
||||
} else {
|
||||
HttpClientManager.getHttpClient()
|
||||
}
|
||||
val brief = RelayBriefInfoCache.get(url)
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
private var isReady: Boolean = false
|
||||
private var usingCompression: Boolean = false
|
||||
|
||||
var eventDownloadCounterInBytes = 0
|
||||
var eventUploadCounterInBytes = 0
|
||||
private var lastConnectTentative: Long = 0L
|
||||
|
||||
var spamCounter = 0
|
||||
var errorCounter = 0
|
||||
var pingInMs: Long? = null
|
||||
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
var lastConnectTentative: Long = 0L
|
||||
|
||||
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
val sendWhenReady = mutableListOf<EventInterface>()
|
||||
private val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
private val sendWhenReady = mutableListOf<EventInterface>()
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
@@ -116,7 +103,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Relay", "Relay.connect $url connecting: ${connectingBlock.get()} hasProxy: ${this.httpClient.proxy != null}")
|
||||
Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingBlock.get()}")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -141,13 +128,13 @@ class Relay(
|
||||
.url(url.trim())
|
||||
.build()
|
||||
|
||||
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
|
||||
socket = HttpClientManager.getHttpClientForUrl(url).newWebSocket(request, RelayListener(onConnected))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
errorCounter++
|
||||
RelayStats.newError(url, e.message)
|
||||
|
||||
markConnectionAsClosed()
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
connectingBlock.set(false)
|
||||
@@ -187,7 +174,7 @@ class Relay(
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
RelayStats.addBytesReceived(url, text.bytesUsedInMemory())
|
||||
|
||||
try {
|
||||
processNewRelayMessage(text)
|
||||
@@ -239,21 +226,25 @@ class Relay(
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
errorCounter++
|
||||
|
||||
socket?.cancel() // 1000, "Normal close"
|
||||
|
||||
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
|
||||
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
|
||||
RelayStats.newError(url, response?.message ?: t.message)
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response ${t.message} $socket")
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(
|
||||
this@Relay,
|
||||
"",
|
||||
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failures disconnect the relay.
|
||||
markConnectionAsClosed()
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(
|
||||
this@Relay,
|
||||
"",
|
||||
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +254,9 @@ class Relay(
|
||||
) {
|
||||
this.resetEOSEStatuses()
|
||||
this.isReady = true
|
||||
this.pingInMs = pingInMs
|
||||
this.usingCompression = usingCompression
|
||||
|
||||
RelayStats.setPing(url, pingInMs)
|
||||
}
|
||||
|
||||
fun markConnectionAsClosed() {
|
||||
@@ -306,6 +298,8 @@ class Relay(
|
||||
val message = msgArray.get(1).asText()
|
||||
Log.w("Relay", "Relay onNotice $url, $message")
|
||||
|
||||
RelayStats.newNotice(url, message)
|
||||
|
||||
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
|
||||
}
|
||||
"OK" ->
|
||||
@@ -336,7 +330,9 @@ class Relay(
|
||||
it.onNotify(this@Relay, msgArray[1].asText())
|
||||
}
|
||||
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
|
||||
else ->
|
||||
else -> {
|
||||
RelayStats.newError(url, "Unsupported message: $newMessage")
|
||||
|
||||
listeners.forEach {
|
||||
Log.w("Relay", "Unsupported message: $newMessage")
|
||||
it.onError(
|
||||
@@ -345,6 +341,7 @@ class Relay(
|
||||
Error("Unknown type $type on channel. Msg was $newMessage"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +386,8 @@ class Relay(
|
||||
it.filter.toJson(url)
|
||||
}
|
||||
|
||||
// Log.d("Relay", "onFilterSent $url $requestId $request")
|
||||
writeToSocket(request)
|
||||
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
resetEOSEStatuses()
|
||||
}
|
||||
}
|
||||
@@ -457,30 +452,9 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
authResponse.put(signedEvent.id, false)
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
sendAuth(signedEvent)
|
||||
} else {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
sendEvent(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,18 +462,12 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
authResponse.put(signedEvent.id, false)
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
sendAuth(signedEvent)
|
||||
} else {
|
||||
if (write) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
|
||||
} else {
|
||||
synchronized(sendWhenReady) {
|
||||
sendWhenReady.add(signedEvent)
|
||||
@@ -508,10 +476,7 @@ class Relay(
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
|
||||
|
||||
// Sends everything.
|
||||
renewFilters()
|
||||
@@ -521,19 +486,49 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val msg = """["CLOSE","$subscriptionId"]"""
|
||||
// Log.d("Relay", "Close Subscription $url $msg")
|
||||
socket?.send(msg)
|
||||
private fun sendAuth(signedEvent: RelayAuthEvent) {
|
||||
authResponse.put(signedEvent.id, false)
|
||||
writeToSocket("""["AUTH",${signedEvent.toJson()}]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: Relay): Boolean {
|
||||
private fun sendEvent(signedEvent: EventInterface) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
|
||||
}
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
|
||||
|
||||
// Sends everything.
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeToSocket(str: String) {
|
||||
socket?.let {
|
||||
checkNotInMainThread()
|
||||
|
||||
it.send(str)
|
||||
RelayStats.addBytesSent(url, str.bytesUsedInMemory())
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("Relay", "Relay send $url $str")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
writeToSocket("""["CLOSE","$subscriptionId"]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: RelaySetupInfo): Boolean {
|
||||
return url == other.url &&
|
||||
write == other.write &&
|
||||
read == other.read &&
|
||||
activeTypes == other.activeTypes
|
||||
activeTypes == other.feedTypes
|
||||
}
|
||||
|
||||
enum class StateType {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
@@ -91,6 +92,7 @@ object RelayPool : Relay.Listener {
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
timeout: Long = 60000,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
addRelay(relay)
|
||||
@@ -103,7 +105,7 @@ object RelayPool : Relay.Listener {
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits for a reply
|
||||
delay(timeout) // waits for a reply
|
||||
relay.disconnect()
|
||||
removeRelay(relay)
|
||||
|
||||
@@ -147,7 +149,7 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
fun sendToSelectedRelays(
|
||||
list: List<Relay>,
|
||||
list: List<RelaySetupInfo>,
|
||||
signedEvent: EventInterface,
|
||||
) {
|
||||
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } }
|
||||
@@ -197,12 +199,6 @@ object RelayPool : Relay.Listener {
|
||||
afterEOSE: Boolean,
|
||||
)
|
||||
|
||||
fun onError(
|
||||
error: Error,
|
||||
subscriptionId: String,
|
||||
relay: Relay,
|
||||
)
|
||||
|
||||
fun onRelayStateChange(
|
||||
type: Relay.StateType,
|
||||
relay: Relay,
|
||||
@@ -241,7 +237,6 @@ object RelayPool : Relay.Listener {
|
||||
subscriptionId: String,
|
||||
error: Error,
|
||||
) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
object RelayStats {
|
||||
private val innerCache = mutableMapOf<String, RelayStat>()
|
||||
|
||||
fun get(url: String): RelayStat {
|
||||
return innerCache.getOrPut(url) { RelayStat() }
|
||||
}
|
||||
|
||||
fun addBytesReceived(
|
||||
url: String,
|
||||
bytesUsedInMemory: Int,
|
||||
) {
|
||||
get(url).addBytesReceived(bytesUsedInMemory)
|
||||
}
|
||||
|
||||
fun addBytesSent(
|
||||
url: String,
|
||||
bytesUsedInMemory: Int,
|
||||
) {
|
||||
get(url).addBytesSent(bytesUsedInMemory)
|
||||
}
|
||||
|
||||
fun newError(
|
||||
url: String,
|
||||
error: String?,
|
||||
) {
|
||||
get(url).newError(error)
|
||||
}
|
||||
|
||||
fun newNotice(
|
||||
url: String,
|
||||
notice: String?,
|
||||
) {
|
||||
get(url).newNotice(notice)
|
||||
}
|
||||
|
||||
fun setPing(
|
||||
url: String,
|
||||
pingInMs: Long,
|
||||
) {
|
||||
get(url).pingInMs = pingInMs
|
||||
}
|
||||
|
||||
fun newSpam(
|
||||
url: String,
|
||||
explanation: String,
|
||||
) {
|
||||
get(url).newSpam(explanation)
|
||||
}
|
||||
}
|
||||
|
||||
class RelayStat(
|
||||
var receivedBytes: Long = 0L,
|
||||
var sentBytes: Long = 0L,
|
||||
var spamCounter: Long = 0L,
|
||||
var errorCounter: Long = 0L,
|
||||
var pingInMs: Long = 0L,
|
||||
) {
|
||||
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
|
||||
|
||||
fun newNotice(notice: String?) {
|
||||
val debugMessage =
|
||||
RelayDebugMessage(
|
||||
type = RelayDebugMessageType.NOTICE,
|
||||
message = notice ?: "No error message provided",
|
||||
)
|
||||
|
||||
messages.put(debugMessage, debugMessage)
|
||||
}
|
||||
|
||||
fun newError(error: String?) {
|
||||
errorCounter++
|
||||
|
||||
val debugMessage =
|
||||
RelayDebugMessage(
|
||||
type = RelayDebugMessageType.ERROR,
|
||||
message = error ?: "No error message provided",
|
||||
)
|
||||
|
||||
messages.put(debugMessage, debugMessage)
|
||||
}
|
||||
|
||||
fun addBytesReceived(bytesUsedInMemory: Int) {
|
||||
receivedBytes += bytesUsedInMemory
|
||||
}
|
||||
|
||||
fun addBytesSent(bytesUsedInMemory: Int) {
|
||||
sentBytes += bytesUsedInMemory
|
||||
}
|
||||
|
||||
fun newSpam(spamDescriptor: String) {
|
||||
spamCounter++
|
||||
|
||||
val debugMessage =
|
||||
RelayDebugMessage(
|
||||
type = RelayDebugMessageType.SPAM,
|
||||
message = spamDescriptor,
|
||||
)
|
||||
|
||||
messages.put(debugMessage, debugMessage)
|
||||
}
|
||||
}
|
||||
|
||||
class RelayDebugMessage(
|
||||
val type: RelayDebugMessageType,
|
||||
val message: String,
|
||||
val time: Long = TimeUtils.now(),
|
||||
)
|
||||
|
||||
enum class RelayDebugMessageType {
|
||||
SPAM,
|
||||
NOTICE,
|
||||
ERROR,
|
||||
}
|
||||
@@ -45,6 +45,10 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(act.isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
|
||||
}
|
||||
|
||||
@@ -304,11 +304,13 @@ fun EditPostView(
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
|
||||
}
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
@@ -323,7 +325,7 @@ fun EditPostView(
|
||||
nav = nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
@@ -109,11 +109,11 @@ open class EditPostViewModel() : ViewModel() {
|
||||
editedFromNote = edit
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<Relay>? = null) {
|
||||
suspend fun innerSendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
|
||||
@@ -29,6 +29,8 @@ import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -45,6 +47,33 @@ import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImageSaver {
|
||||
fun saveImage(
|
||||
videoUri: String?,
|
||||
mimeType: String?,
|
||||
localContext: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
if (videoUri != null) {
|
||||
if (!videoUri.startsWith("file")) {
|
||||
saveImage(
|
||||
context = localContext,
|
||||
url = videoUri,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
)
|
||||
} else {
|
||||
saveImage(
|
||||
context = localContext,
|
||||
localFile = videoUri.toUri().toFile(),
|
||||
mimeType = mimeType,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image to the gallery. May require a storage permission.
|
||||
*
|
||||
|
||||
@@ -31,10 +31,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -80,7 +80,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
|
||||
@@ -188,7 +188,7 @@ open class NewMediaModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
context: Context,
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
@@ -272,7 +272,7 @@ open class NewMediaModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
|
||||
@@ -251,7 +251,9 @@ fun ImageVideoPost(
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(
|
||||
videoUri = it.toString(),
|
||||
mimeType = postViewModel.mediaType,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,11 +425,13 @@ fun NewPostView(
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
|
||||
}
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
@@ -444,7 +446,7 @@ fun NewPostView(
|
||||
nav = nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1736,7 +1738,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel)
|
||||
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -438,7 +438,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerSendPost(relayList, null)
|
||||
accountViewModel?.deleteDraft(draftTag)
|
||||
@@ -446,18 +446,18 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDraft(relayList: List<Relay>? = null) {
|
||||
fun sendDraft(relayList: List<RelaySetupInfo>? = null) {
|
||||
viewModelScope.launch {
|
||||
sendDraftSync(relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync(relayList: List<Relay>? = null) {
|
||||
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>? = null) {
|
||||
innerSendPost(relayList, draftTag)
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
localDraft: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (accountViewModel == null) {
|
||||
|
||||
@@ -63,15 +63,16 @@ fun NotifyRequestDialog(
|
||||
val background = remember { mutableStateOf(defaultBackground) }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
textContent,
|
||||
content = textContent,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
Modifier.fillMaxWidth(),
|
||||
EmptyTagList,
|
||||
background,
|
||||
textContent,
|
||||
accountViewModel,
|
||||
nav,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = background,
|
||||
id = textContent,
|
||||
callbackUri = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -47,8 +47,8 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -57,7 +57,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
val relay: RelaySetupInfo,
|
||||
val relayInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
@@ -69,9 +69,9 @@ data class RelayInfoDialog(
|
||||
|
||||
@Composable
|
||||
fun RelaySelectionDialog(
|
||||
preSelectedList: ImmutableList<Relay>,
|
||||
preSelectedList: ImmutableList<RelaySetupInfo>,
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: ImmutableList<Relay>) -> Unit,
|
||||
onPost: (list: ImmutableList<RelaySetupInfo>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
||||
+60
-65
@@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class RangesChanges(val original: TextRange, val modified: TextRange)
|
||||
|
||||
@@ -55,71 +54,69 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
|
||||
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
|
||||
append(
|
||||
text
|
||||
.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph
|
||||
.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
text.text.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
|
||||
val startIndex = builderBefore.toString().length
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append(
|
||||
"$keyB32$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
builderBefore.append(
|
||||
"$keyB32$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyB32.length
|
||||
val endIndex = startIndex + keyB32.length
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
|
||||
builderAfter.append(
|
||||
"$newWord$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
builderAfter.append(
|
||||
"$newWord$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, startNew + newWord.length),
|
||||
),
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
val startIndex = builderBefore.toString().length
|
||||
val endIndex = startIndex + word.length
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, startNew + newWord.length),
|
||||
),
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
val startIndex = builderBefore.toString().length
|
||||
val endIndex = startIndex + word.length
|
||||
|
||||
val startNew = builderAfter.toString().length
|
||||
val endNew = startNew + word.length
|
||||
val startNew = builderAfter.toString().length
|
||||
val endNew = startNew + word.length
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, endNew),
|
||||
),
|
||||
)
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, endNew),
|
||||
),
|
||||
)
|
||||
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
} else {
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
} else {
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
},
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
substitutions.forEach {
|
||||
@@ -144,16 +141,15 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
(offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
|
||||
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange)
|
||||
.roundToInt()
|
||||
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
} else {
|
||||
return offset
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,16 +160,15 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
(offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
|
||||
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange)
|
||||
.roundToInt()
|
||||
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
return offset
|
||||
offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStat
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -236,7 +237,7 @@ fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
Constants.defaultRelays.forEach { postViewModel.addRelay(it) }
|
||||
postViewModel.addAll(Constants.defaultRelays)
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
@@ -249,7 +250,7 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it)) }
|
||||
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
|
||||
+15
-4
@@ -22,14 +22,25 @@ package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStat
|
||||
|
||||
@Immutable
|
||||
data class BasicRelaySetupInfo(
|
||||
val url: String,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val relayStat: RelayStat,
|
||||
val paidRelay: Boolean = false,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Kind3BasicRelaySetupInfo(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val relayStat: RelayStat,
|
||||
val paidRelay: Boolean = false,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
|
||||
+5
-13
@@ -24,7 +24,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -78,20 +79,11 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
|
||||
val relayList = getRelayList() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
RelayUrlFormatter.normalize(relayUrl),
|
||||
RelayStats.get(relayUrl),
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ fun countToHumanReadableBytes(counter: Int) =
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
fun countToHumanReadableBytes(counter: Long) =
|
||||
when {
|
||||
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
|
||||
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
|
||||
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
fun countToHumanReadable(
|
||||
counter: Int,
|
||||
str: String,
|
||||
@@ -37,3 +45,13 @@ fun countToHumanReadable(
|
||||
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
|
||||
else -> "$counter $str"
|
||||
}
|
||||
|
||||
fun countToHumanReadable(
|
||||
counter: Long,
|
||||
str: String,
|
||||
) = when {
|
||||
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
|
||||
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
|
||||
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
|
||||
else -> "$counter $str"
|
||||
}
|
||||
|
||||
+52
-49
@@ -68,11 +68,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStat
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -95,7 +95,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun Kind3RelayListView(
|
||||
feedState: List<RelaySetupInfo>,
|
||||
feedState: List<Kind3BasicRelaySetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
@@ -110,7 +110,7 @@ fun Kind3RelayListView(
|
||||
}
|
||||
|
||||
fun LazyListScope.renderKind3Items(
|
||||
feedState: List<RelaySetupInfo>,
|
||||
feedState: List<Kind3BasicRelaySetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
@@ -147,14 +147,17 @@ fun ServerConfigPreview() {
|
||||
ClickableRelayItem(
|
||||
loadProfilePicture = true,
|
||||
item =
|
||||
RelaySetupInfo(
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = "nostr.mom",
|
||||
read = true,
|
||||
write = true,
|
||||
errorCount = 23,
|
||||
downloadCountInBytes = 10000,
|
||||
uploadCountInBytes = 10000000,
|
||||
spamCount = 10,
|
||||
relayStat =
|
||||
RelayStat(
|
||||
errorCounter = 23,
|
||||
receivedBytes = 10000,
|
||||
sentBytes = 10000000,
|
||||
spamCounter = 10,
|
||||
),
|
||||
feedTypes = Constants.activeTypesGlobalChats,
|
||||
paidRelay = true,
|
||||
),
|
||||
@@ -172,15 +175,15 @@ fun ServerConfigPreview() {
|
||||
|
||||
@Composable
|
||||
fun LoadRelayInfo(
|
||||
item: RelaySetupInfo,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
onDelete: (RelaySetupInfo) -> Unit,
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -263,16 +266,16 @@ fun LoadRelayInfo(
|
||||
|
||||
@Composable
|
||||
fun ClickableRelayItem(
|
||||
item: RelaySetupInfo,
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
loadProfilePicture: Boolean,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
onDelete: (RelaySetupInfo) -> Unit,
|
||||
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
@@ -333,9 +336,9 @@ fun ClickableRelayItem(
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun StatusRow(
|
||||
item: RelaySetupInfo,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -370,7 +373,7 @@ private fun StatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.downloadCountInBytes),
|
||||
text = countToHumanReadableBytes(item.relayStat.receivedBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -406,7 +409,7 @@ private fun StatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.uploadCountInBytes),
|
||||
text = countToHumanReadableBytes(item.relayStat.sentBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -432,7 +435,7 @@ private fun StatusRow(
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.errorCount > 0) {
|
||||
if (item.relayStat.errorCounter > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
@@ -440,7 +443,7 @@ private fun StatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.errorCount, "errors"),
|
||||
text = countToHumanReadable(item.relayStat.errorCounter, "errors"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -466,7 +469,7 @@ private fun StatusRow(
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.spamCount > 0) {
|
||||
if (item.relayStat.spamCounter > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
@@ -474,7 +477,7 @@ private fun StatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.spamCount, "spam"),
|
||||
text = countToHumanReadable(item.relayStat.spamCounter, "spam"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -485,11 +488,11 @@ private fun StatusRow(
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun ActiveToggles(
|
||||
item: RelaySetupInfo,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
@@ -639,9 +642,9 @@ private fun ActiveToggles(
|
||||
|
||||
@Composable
|
||||
private fun FirstLine(
|
||||
item: RelaySetupInfo,
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
onClick: () -> Unit,
|
||||
onDelete: (RelaySetupInfo) -> Unit,
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
@@ -680,7 +683,7 @@ private fun FirstLine(
|
||||
@Composable
|
||||
fun Kind3RelayEditBox(
|
||||
relayToAdd: String,
|
||||
onNewRelay: (RelaySetupInfo) -> Unit,
|
||||
onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
) {
|
||||
var url by remember { mutableStateOf<String>(relayToAdd) }
|
||||
var read by remember { mutableStateOf(true) }
|
||||
@@ -733,13 +736,13 @@ fun Kind3RelayEditBox(
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank() && url != "/") {
|
||||
val addedWSS = RelayUrlFormatter.normalize(url)
|
||||
onNewRelay(
|
||||
RelaySetupInfo(
|
||||
addedWSS,
|
||||
read,
|
||||
write,
|
||||
feedTypes = FeedType.values().toSet(),
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(url),
|
||||
read = read,
|
||||
write = write,
|
||||
feedTypes = FeedType.entries.toSet(),
|
||||
relayStat = RelayStat(),
|
||||
),
|
||||
)
|
||||
url = ""
|
||||
|
||||
+56
-46
@@ -27,7 +27,8 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -38,7 +39,7 @@ import kotlinx.coroutines.launch
|
||||
class Kind3RelayListViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
|
||||
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
var hasModified = false
|
||||
@@ -52,7 +53,16 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
fun create() {
|
||||
if (hasModified) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.saveKind3RelayList(relays.value)
|
||||
account.saveKind3RelayList(
|
||||
relays.value.map {
|
||||
RelaySetupInfo(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
)
|
||||
},
|
||||
)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
@@ -80,7 +90,6 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
if (relayFile != null) {
|
||||
relayFile
|
||||
.map {
|
||||
val liveRelay = RelayPool.getRelay(it.key)
|
||||
val localInfoFeedTypes =
|
||||
account.localRelays
|
||||
.filter { localRelay -> localRelay.url == it.key }
|
||||
@@ -92,54 +101,55 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
?.feedTypes
|
||||
?: FeedType.values().toSet().toImmutableSet()
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(
|
||||
it.key,
|
||||
it.value.read,
|
||||
it.value.write,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
localInfoFeedTypes,
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(it.key),
|
||||
read = it.value.read,
|
||||
write = it.value.write,
|
||||
feedTypes = localInfoFeedTypes,
|
||||
relayStat = RelayStats.get(it.key),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.url }
|
||||
.sortedBy { it.downloadCountInBytes }
|
||||
.sortedBy { it.relayStat.receivedBytes }
|
||||
.reversed()
|
||||
} else {
|
||||
account.localRelays
|
||||
.map {
|
||||
val liveRelay = RelayPool.getRelay(it.url)
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
it.feedTypes,
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(it.url),
|
||||
read = it.read,
|
||||
write = it.write,
|
||||
feedTypes = it.feedTypes,
|
||||
relayStat = RelayStats.get(it.url),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.url }
|
||||
.sortedBy { it.downloadCountInBytes }
|
||||
.sortedBy { it.relayStat.receivedBytes }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: RelaySetupInfo) {
|
||||
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
|
||||
hasModified = true
|
||||
|
||||
_relays.update {
|
||||
defaultRelays.map {
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(it.url),
|
||||
read = it.read,
|
||||
write = it.write,
|
||||
feedTypes = it.feedTypes,
|
||||
relayStat = RelayStats.get(it.url),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.url }
|
||||
.sortedBy { it.relayStat.receivedBytes }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Kind3BasicRelaySetupInfo) {
|
||||
if (relays.value.any { it.url == relay.url }) return
|
||||
|
||||
_relays.update { it.plus(relay) }
|
||||
@@ -147,7 +157,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteRelay(relay: RelaySetupInfo) {
|
||||
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
|
||||
_relays.update { it.minus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
@@ -157,48 +167,48 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleDownload(relay: RelaySetupInfo) {
|
||||
fun toggleDownload(relay: Kind3BasicRelaySetupInfo) {
|
||||
_relays.update { it.updated(relay, relay.copy(read = !relay.read)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleUpload(relay: RelaySetupInfo) {
|
||||
fun toggleUpload(relay: Kind3BasicRelaySetupInfo) {
|
||||
_relays.update { it.updated(relay, relay.copy(write = !relay.write)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleFollows(relay: RelaySetupInfo) {
|
||||
fun toggleFollows(relay: Kind3BasicRelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleMessages(relay: RelaySetupInfo) {
|
||||
fun toggleMessages(relay: Kind3BasicRelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun togglePublicChats(relay: RelaySetupInfo) {
|
||||
fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleGlobal(relay: RelaySetupInfo) {
|
||||
fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleSearch(relay: RelaySetupInfo) {
|
||||
fun toggleSearch(relay: Kind3BasicRelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun togglePaidRelay(
|
||||
relay: RelaySetupInfo,
|
||||
relay: Kind3BasicRelaySetupInfo,
|
||||
paid: Boolean,
|
||||
) {
|
||||
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
|
||||
|
||||
+8
-25
@@ -24,7 +24,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -105,40 +106,22 @@ class Nip65RelayListViewModel : ViewModel() {
|
||||
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
RelayUrlFormatter.normalize(relayUrl),
|
||||
RelayStats.get(relayUrl),
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
|
||||
}
|
||||
|
||||
_notificationRelays.update {
|
||||
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
RelayUrlFormatter.normalize(relayUrl),
|
||||
RelayStats.get(relayUrl),
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+191
-135
@@ -31,15 +31,18 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,18 +51,24 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStats
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -70,6 +79,11 @@ fun RelayInformationDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val messages =
|
||||
remember(relayBriefInfo) {
|
||||
RelayStats.get(url = relayBriefInfo.url).messages.snapshot().values.sortedByDescending { it.time }.toImmutableList()
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
@@ -84,162 +98,204 @@ fun RelayInformationDialog(
|
||||
),
|
||||
) {
|
||||
Surface {
|
||||
val scrollState = rememberScrollState()
|
||||
val color = mutableStateOf(Color.Transparent)
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState),
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CloseButton(onPress = { onClose() })
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = StdPadding.fillMaxWidth(),
|
||||
) {
|
||||
Column {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.displayUrl,
|
||||
relayInfo.icon ?: relayBriefInfo.favIcon,
|
||||
automaticallyShowProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row { Title(relayInfo.name?.trim() ?: "") }
|
||||
|
||||
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CloseButton(onPress = { onClose() })
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = StdPadding.fillMaxWidth(),
|
||||
) {
|
||||
Column {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.displayUrl,
|
||||
relayInfo.icon ?: relayBriefInfo.favIcon,
|
||||
automaticallyShowProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.owner))
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
relayInfo.pubkey?.let {
|
||||
DisplayOwnerInformation(it, accountViewModel) {
|
||||
onClose()
|
||||
nav(it)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row { Title(relayInfo.name?.trim() ?: "") }
|
||||
|
||||
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Section(stringResource(R.string.owner))
|
||||
|
||||
Section(stringResource(R.string.software))
|
||||
relayInfo.pubkey?.let {
|
||||
DisplayOwnerInformation(it, accountViewModel) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Section(stringResource(R.string.software))
|
||||
|
||||
DisplaySoftwareInformation(relayInfo)
|
||||
DisplaySoftwareInformation(relayInfo)
|
||||
|
||||
Section(stringResource(R.string.version))
|
||||
Section(stringResource(R.string.version))
|
||||
|
||||
SectionContent(relayInfo.version ?: "")
|
||||
SectionContent(relayInfo.version ?: "")
|
||||
}
|
||||
item {
|
||||
Section(stringResource(R.string.contact))
|
||||
|
||||
Section(stringResource(R.string.contact))
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
relayInfo.contact?.let {
|
||||
if (it.startsWith("https:")) {
|
||||
ClickableUrl(urlText = it, url = it)
|
||||
} else if (it.startsWith("mailto:") || it.contains('@')) {
|
||||
ClickableEmail(it)
|
||||
} else {
|
||||
SectionContent(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Section(stringResource(R.string.supports))
|
||||
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
relayInfo.contact?.let {
|
||||
if (it.startsWith("https:")) {
|
||||
ClickableUrl(urlText = it, url = it)
|
||||
} else if (it.startsWith("mailto:") || it.contains('@')) {
|
||||
ClickableEmail(it)
|
||||
} else {
|
||||
SectionContent(it)
|
||||
DisplaySupportedNips(relayInfo)
|
||||
}
|
||||
item {
|
||||
relayInfo.fees?.admission?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
Section(stringResource(R.string.admission_fees))
|
||||
|
||||
it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") }
|
||||
}
|
||||
}
|
||||
|
||||
relayInfo.payments_url?.let {
|
||||
Section(stringResource(R.string.payments_url))
|
||||
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = it,
|
||||
url = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
relayInfo.limitation?.let {
|
||||
Section(stringResource(R.string.limitations))
|
||||
val authRequiredText =
|
||||
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val paymentRequiredText =
|
||||
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val restrictedWritesText =
|
||||
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
Column {
|
||||
SectionContent(
|
||||
"${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
|
||||
SectionContent(
|
||||
"${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
|
||||
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
|
||||
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
|
||||
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
relayInfo.relay_countries?.let {
|
||||
Section(stringResource(R.string.countries))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
}
|
||||
item {
|
||||
relayInfo.language_tags?.let {
|
||||
Section(stringResource(R.string.languages))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
}
|
||||
item {
|
||||
relayInfo.tags?.let {
|
||||
Section(stringResource(R.string.tags))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
}
|
||||
item {
|
||||
relayInfo.posting_policy?.let {
|
||||
Section(stringResource(R.string.posting_policy))
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
it,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.supports))
|
||||
|
||||
DisplaySupportedNips(relayInfo)
|
||||
|
||||
relayInfo.fees?.admission?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
Section(stringResource(R.string.admission_fees))
|
||||
|
||||
it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") }
|
||||
}
|
||||
item {
|
||||
Section(stringResource(R.string.relay_error_messages))
|
||||
}
|
||||
|
||||
relayInfo.payments_url?.let {
|
||||
Section(stringResource(R.string.payments_url))
|
||||
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = it,
|
||||
url = it,
|
||||
items(messages) { msg ->
|
||||
Row {
|
||||
TranslatableRichTextViewer(
|
||||
content =
|
||||
remember {
|
||||
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
|
||||
},
|
||||
canPreview = false,
|
||||
quotesLeft = 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = color,
|
||||
id = msg.hashCode().toString(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
relayInfo.limitation?.let {
|
||||
Section(stringResource(R.string.limitations))
|
||||
val authRequiredText =
|
||||
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val paymentRequiredText =
|
||||
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val restrictedWritesText =
|
||||
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
Column {
|
||||
SectionContent(
|
||||
"${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
|
||||
SectionContent(
|
||||
"${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
|
||||
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
|
||||
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
|
||||
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
|
||||
}
|
||||
}
|
||||
|
||||
relayInfo.relay_countries?.let {
|
||||
Section(stringResource(R.string.countries))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
|
||||
relayInfo.language_tags?.let {
|
||||
Section(stringResource(R.string.languages))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
|
||||
relayInfo.tags?.let {
|
||||
Section(stringResource(R.string.tags))
|
||||
|
||||
FlowRow { it.forEach { item -> SectionContent(item) } }
|
||||
}
|
||||
|
||||
relayInfo.posting_policy?.let {
|
||||
Section(stringResource(R.string.posting_policy))
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
it,
|
||||
it,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ fun RelayStatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.downloadCountInBytes),
|
||||
text = countToHumanReadableBytes(item.relayStat.receivedBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -101,7 +101,7 @@ fun RelayStatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.uploadCountInBytes),
|
||||
text = countToHumanReadableBytes(item.relayStat.sentBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -124,7 +124,7 @@ fun RelayStatusRow(
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.errorCount > 0) {
|
||||
if (item.relayStat.errorCounter > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
@@ -132,7 +132,7 @@ fun RelayStatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.errorCount, "errors"),
|
||||
text = countToHumanReadable(item.relayStat.errorCounter, "errors"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
@@ -163,7 +163,7 @@ fun RelayStatusRow(
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.spamCount > 0) {
|
||||
if (item.relayStat.spamCounter > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
@@ -171,7 +171,7 @@ fun RelayStatusRow(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.spamCount, "spam"),
|
||||
text = countToHumanReadable(item.relayStat.spamCounter, "spam"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayStat
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -65,8 +66,7 @@ fun RelayUrlEditField(onNewRelay: (BasicRelaySetupInfo) -> Unit) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank() && url != "/") {
|
||||
val addedWSS = RelayUrlFormatter.normalize(url)
|
||||
onNewRelay(BasicRelaySetupInfo(addedWSS))
|
||||
onNewRelay(BasicRelaySetupInfo(RelayUrlFormatter.normalize(url), RelayStat()))
|
||||
url = ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@Composable
|
||||
fun ClickableUrl(
|
||||
@@ -39,6 +40,8 @@ fun ClickableUrl(
|
||||
|
||||
ClickableText(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onClick = {
|
||||
runCatching {
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
|
||||
@@ -64,6 +64,7 @@ fun ExpandableRichTextViewer(
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -99,6 +100,7 @@ fun ExpandableRichTextViewer(
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
callbackUri,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
fun LoadUrlPreview(
|
||||
url: String,
|
||||
urlText: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value }
|
||||
@@ -61,7 +62,7 @@ fun LoadUrlPreview(
|
||||
) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
RenderLoaded(state, url, accountViewModel)
|
||||
RenderLoaded(state, url, callbackUri, accountViewModel)
|
||||
}
|
||||
else -> {
|
||||
ClickableUrl(urlText, url)
|
||||
@@ -75,21 +76,24 @@ fun LoadUrlPreview(
|
||||
fun RenderLoaded(
|
||||
state: UrlPreviewState.Loaded,
|
||||
url: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url),
|
||||
content = MediaUrlImage(url, uri = callbackUri),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url),
|
||||
content = MediaUrlVideo(url, uri = callbackUri),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,14 +121,15 @@ fun RichTextViewer(
|
||||
modifier: Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,7 +268,7 @@ fun RenderRegularPreview3() {
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, null, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||
@@ -291,16 +292,18 @@ private fun RenderRegular(
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
RenderRegular(content, tags) { word, state ->
|
||||
RenderRegular(content, tags, callbackUri) { word, state ->
|
||||
if (canPreview) {
|
||||
RenderWordWithPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
quotesLeft,
|
||||
callbackUri,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
@@ -321,9 +324,10 @@ private fun RenderRegular(
|
||||
fun RenderRegular(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
|
||||
) {
|
||||
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
|
||||
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) }
|
||||
|
||||
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
|
||||
|
||||
@@ -412,12 +416,13 @@ private fun RenderWordWithPreview(
|
||||
state: RichTextViewerState,
|
||||
backgroundColor: MutableState<Color>,
|
||||
quotesLeft: Int,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
when (word) {
|
||||
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||
@@ -441,7 +446,7 @@ private fun ZoomableContentView(
|
||||
) {
|
||||
state.imagesForPager[word]?.let {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel)
|
||||
ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -36,7 +38,6 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -44,17 +45,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,13 +78,10 @@ import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -89,10 +92,15 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
@@ -100,9 +108,12 @@ import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MutedIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size110dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size165dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
@@ -123,10 +134,12 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
title: String? = null,
|
||||
thumbUri: String,
|
||||
authorName: String? = null,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
nostrUriCallback: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
@@ -139,11 +152,12 @@ fun LoadThumbAndThenVideoView(
|
||||
context,
|
||||
thumbUri,
|
||||
onReady = {
|
||||
if (it != null) {
|
||||
loadingFinished = Pair(true, it)
|
||||
} else {
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
loadingFinished =
|
||||
if (it != null) {
|
||||
Pair(true, it)
|
||||
} else {
|
||||
Pair(true, null)
|
||||
}
|
||||
},
|
||||
onError = { loadingFinished = Pair(true, null) },
|
||||
)
|
||||
@@ -153,9 +167,11 @@ fun LoadThumbAndThenVideoView(
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
@@ -165,9 +181,11 @@ fun LoadThumbAndThenVideoView(
|
||||
} else {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = null,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
@@ -181,10 +199,11 @@ fun LoadThumbAndThenVideoView(
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
isFiniteHeight: Boolean,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
@@ -208,7 +227,7 @@ fun VideoView(
|
||||
if (blurhash == null) {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val modifier =
|
||||
if (ratio != null && roundedCorner && automaticallyStartPlayback.value) {
|
||||
if (ratio != null && automaticallyStartPlayback.value) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
@@ -220,40 +239,50 @@ fun VideoView(
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
|
||||
val modifier =
|
||||
if (ratio != null && roundedCorner) {
|
||||
if (ratio != null) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
val image =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
// Always displays Blurharh to avoid size flickering
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
|
||||
if (ratio != null) image.aspectRatio(ratio) else modifier,
|
||||
)
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
ContentScale.Crop,
|
||||
MaterialTheme.colorScheme.imageModifier,
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { automaticallyStartPlayback.value = true },
|
||||
@@ -263,20 +292,20 @@ fun VideoView(
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -287,20 +316,20 @@ fun VideoView(
|
||||
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun VideoViewInner(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
defaultToStart: Boolean = false,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
isFiniteHeight: Boolean,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
|
||||
@@ -311,12 +340,13 @@ fun VideoViewInner(
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
@@ -324,6 +354,7 @@ fun VideoViewInner(
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,18 +372,18 @@ data class MediaItemData(
|
||||
)
|
||||
|
||||
class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) {
|
||||
override suspend fun compute(data: MediaItemData): MediaItem? {
|
||||
override suspend fun compute(key: MediaItemData): MediaItem {
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(data.videoUri)
|
||||
.setUri(data.videoUri)
|
||||
.setMediaId(key.videoUri)
|
||||
.setUri(key.videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(data.authorName?.ifBlank { null })
|
||||
.setTitle(data.title?.ifBlank { null } ?: data.videoUri)
|
||||
.setArtist(key.authorName?.ifBlank { null })
|
||||
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (data.artworkUri != null) {
|
||||
Uri.parse(data.artworkUri)
|
||||
if (key.artworkUri != null) {
|
||||
Uri.parse(key.artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -484,14 +515,20 @@ fun GetVideoController(
|
||||
}
|
||||
|
||||
onDispose {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.let {
|
||||
if (!keepPlaying.value) {
|
||||
// Makes sure the variable is cleared before the task is launched
|
||||
// to avoid the ON_RELEASE running before ON_PAUSE's coroutine
|
||||
val toRelease = controller.value
|
||||
controller.value = null
|
||||
|
||||
toRelease?.let {
|
||||
it.pause()
|
||||
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
// Stops and releases the media.
|
||||
it.stop()
|
||||
it.release()
|
||||
Log.d("PlaybackService", "Releasing Video $videoUri ")
|
||||
controller.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,14 +579,21 @@ fun GetVideoController(
|
||||
}
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.let {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
// Makes sure the variable is cleared before the task is launched
|
||||
// to avoid the ON_RELEASE running before ON_PAUSE's coroutine
|
||||
val toRelease = controller.value
|
||||
controller.value = null
|
||||
|
||||
toRelease?.let {
|
||||
it.pause()
|
||||
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
|
||||
it.stop()
|
||||
it.release()
|
||||
controller.value = null
|
||||
Log.d("PlaybackService", "Released Video from Pause $videoUri ")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,12 +681,13 @@ data class VideoThumb(
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun RenderVideoPlayer(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
roundedCorner: Boolean,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
isFiniteHeight: Boolean,
|
||||
nostrUriCallback: String?,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
@@ -650,25 +695,17 @@ private fun RenderVideoPlayer(
|
||||
modifier: Modifier,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
|
||||
|
||||
val controllerVisible = remember(controller) { mutableStateOf(false) }
|
||||
|
||||
val videoPlaybackHeight = remember { mutableStateOf<Dp>(Dp.Unspecified) }
|
||||
|
||||
val localDensity = LocalDensity.current
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() }
|
||||
},
|
||||
) {
|
||||
Box {
|
||||
val borders = MaterialTheme.colorScheme.imageModifier
|
||||
|
||||
val myModifier =
|
||||
remember {
|
||||
remember(controller) {
|
||||
if (roundedCorner) {
|
||||
modifier.then(
|
||||
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
|
||||
@@ -678,17 +715,6 @@ private fun RenderVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
val ratio = remember { aspectRatio(dimensions) }
|
||||
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio),
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = { context: Context ->
|
||||
@@ -705,7 +731,7 @@ private fun RenderVideoPlayer(
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) {
|
||||
if (isFiniteHeight) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
@@ -730,26 +756,10 @@ private fun RenderVideoPlayer(
|
||||
|
||||
val startingMuteState = remember(controller) { controller.volume < 0.001 }
|
||||
|
||||
val topPadding =
|
||||
remember {
|
||||
derivedStateOf {
|
||||
if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) {
|
||||
val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp
|
||||
if (space > topPaddingForControllers) {
|
||||
Size0dp
|
||||
} else {
|
||||
topPaddingForControllers - space
|
||||
}
|
||||
} else {
|
||||
Size0dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
startingMuteState,
|
||||
topPadding,
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
@@ -767,8 +777,7 @@ private fun RenderVideoPlayer(
|
||||
KeepPlayingButton(
|
||||
keepPlaying,
|
||||
controllerVisible,
|
||||
topPadding,
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
|
||||
) { newKeepPlaying: Boolean ->
|
||||
// If something else is playing and the user marks this video to keep playing, stops the other
|
||||
// one.
|
||||
@@ -786,6 +795,14 @@ private fun RenderVideoPlayer(
|
||||
|
||||
keepPlaying.value = newKeepPlaying
|
||||
}
|
||||
|
||||
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
|
||||
saveImage(videoUri, mimeType, context, accountViewModel)
|
||||
}
|
||||
|
||||
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
|
||||
ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +821,7 @@ fun Waveform(
|
||||
controller: MediaController,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val waveformProgress = remember { mutableStateOf(0F) }
|
||||
val waveformProgress = remember { mutableFloatStateOf(0F) }
|
||||
|
||||
DrawWaveform(waveform, waveformProgress, modifier)
|
||||
|
||||
@@ -818,7 +835,7 @@ fun Waveform(
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
if (isPlaying) {
|
||||
restartFlow.value += 1
|
||||
restartFlow.intValue += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -827,28 +844,28 @@ fun Waveform(
|
||||
onDispose { controller.removeListener(listener) }
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = restartFlow.value) {
|
||||
pollCurrentDuration(controller).collect { value -> waveformProgress.value = value }
|
||||
LaunchedEffect(key1 = restartFlow.intValue) {
|
||||
pollCurrentDuration(controller).collect { value -> waveformProgress.floatValue = value }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawWaveform(
|
||||
waveform: ImmutableList<Int>,
|
||||
waveformProgress: MutableState<Float>,
|
||||
waveformProgress: MutableFloatState,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
AudioWaveformReadOnly(
|
||||
modifier = modifier.padding(start = 10.dp, end = 10.dp),
|
||||
amplitudes = waveform,
|
||||
progress = waveformProgress.value,
|
||||
progress = waveformProgress.floatValue,
|
||||
progressBrush =
|
||||
Brush.infiniteLinearGradient(
|
||||
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
|
||||
animation = tween(durationMillis = 6000, easing = LinearEasing),
|
||||
width = 128F,
|
||||
),
|
||||
onProgressChange = { waveformProgress.value = it },
|
||||
onProgressChange = { waveformProgress.floatValue = it },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -953,7 +970,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
topPadding: State<Dp>,
|
||||
modifier: Modifier,
|
||||
toggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val holdOn =
|
||||
@@ -974,7 +991,7 @@ private fun MuteButton(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
modifier = Modifier.padding(top = topPadding.value),
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
@@ -1007,15 +1024,14 @@ private fun MuteButton(
|
||||
private fun KeepPlayingButton(
|
||||
keepPlayingStart: MutableState<Boolean>,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
topPadding: State<Dp>,
|
||||
alignment: Modifier,
|
||||
modifier: Modifier,
|
||||
toggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = alignment.padding(top = topPadding.value),
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
@@ -1043,3 +1059,125 @@ private fun KeepPlayingButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedSaveButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier,
|
||||
onSaveClick: (localContext: Context) -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
SaveButton(onSaveClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedShareButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier,
|
||||
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
ShareButton(innerAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(
|
||||
Modifier.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
popupExpanded.value = true
|
||||
},
|
||||
modifier = Size50Modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.share_or_save),
|
||||
)
|
||||
|
||||
innerAction(popupExpanded) { popupExpanded.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlin.OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(
|
||||
Modifier.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
onSaveClick(localContext)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
onSaveClick(localContext)
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
modifier = Size50Modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.save_to_gallery),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(
|
||||
videoUri: String?,
|
||||
mimeType: String?,
|
||||
localContext: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ImageSaver.saveImage(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
localContext = localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun ZoomableImageDialog(
|
||||
imageUrl: BaseMediaContent,
|
||||
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val insets = ViewCompat.getRootWindowInsets(view)
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
val activityWindow = getActivityWindow()
|
||||
val dialogWindow = getDialogWindow()
|
||||
val parentView = LocalView.current.parent as View
|
||||
SideEffect {
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = dialogWindow.attributes.type
|
||||
dialogWindow.attributes = attributes
|
||||
parentView.layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
activityWindow.decorView.width,
|
||||
activityWindow.decorView.height,
|
||||
)
|
||||
view.layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
activityWindow.decorView.width,
|
||||
activityWindow.decorView.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = Unit) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.hide(
|
||||
WindowInsets.Type.systemBars(),
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.show(
|
||||
WindowInsets.Type.systemBars(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState { allImages.size }
|
||||
val controllerVisible = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
launch(Dispatchers.Default) {
|
||||
delay(2000)
|
||||
withContext(Dispatchers.Main) {
|
||||
controllerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
allImages.getOrNull(index)?.let {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = it,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = {
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = imageUrl,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = Size15dp, vertical = Size10dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||
}
|
||||
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage(myContent, localContext, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage(myContent, localContext, accountViewModel)
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.save_to_gallery),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(
|
||||
content: BaseMediaContent,
|
||||
localContext: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (content is MediaUrlContent) {
|
||||
ImageSaver.saveImage(
|
||||
content.url,
|
||||
localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
},
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
content.localFile?.let {
|
||||
ImageSaver.saveImage(
|
||||
it,
|
||||
content.mimeType,
|
||||
localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun InlineCarrousel(
|
||||
allImages: ImmutableList<String>,
|
||||
imageUrl: String,
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState { allImages.size }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
AsyncImage(
|
||||
model = allImages[index],
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(
|
||||
content: BaseMediaContent,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
if (content is MediaUrlImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
UrlImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaUrlVideo) {
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else if (content is MediaLocalImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LocalImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaLocalVideo) {
|
||||
content.localFile?.let {
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+258
-597
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -57,6 +57,7 @@ class MarkdownMediaRenderer(
|
||||
val canPreview: Boolean,
|
||||
val quotesLeft: Int,
|
||||
val backgroundColor: MutableState<Color>,
|
||||
val callbackUri: String? = null,
|
||||
val accountViewModel: AccountViewModel,
|
||||
val nav: (String) -> Unit,
|
||||
) : MediaRenderer {
|
||||
@@ -94,6 +95,7 @@ class MarkdownMediaRenderer(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -107,7 +109,7 @@ class MarkdownMediaRenderer(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText)
|
||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
|
||||
|
||||
if (canPreview) {
|
||||
if (content != null) {
|
||||
@@ -115,6 +117,7 @@ class MarkdownMediaRenderer(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -123,7 +126,7 @@ class MarkdownMediaRenderer(
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
} else {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
LoadUrlPreview(uri, title ?: uri, accountViewModel)
|
||||
LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -44,6 +44,7 @@ fun RenderContentAsMarkdown(
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -70,13 +71,14 @@ fun RenderContentAsMarkdown(
|
||||
val renderer =
|
||||
remember(content) {
|
||||
MarkdownMediaRenderer(
|
||||
content.take(100),
|
||||
tags,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
startOfText = content.take(100),
|
||||
tags = tags,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
@@ -59,6 +61,8 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
): Boolean {
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
|
||||
@@ -23,10 +23,13 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
|
||||
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -65,7 +68,12 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
): Boolean {
|
||||
val noteEvent = it.event
|
||||
|
||||
return ((noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isImageOrVideo()) || (noteEvent is FileStorageHeaderEvent && noteEvent.isImageOrVideo())) &&
|
||||
return (
|
||||
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
|
||||
) &&
|
||||
params.match(noteEvent) &&
|
||||
account.isAcceptable(it)
|
||||
}
|
||||
|
||||
@@ -396,9 +396,12 @@ fun AppNavigation(
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let {
|
||||
navController.navigate(it) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
val currentRoute = getRouteWithArguments(navController)
|
||||
if (!isSameRoute(currentRoute, it)) {
|
||||
navController.navigate(it) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -139,7 +140,9 @@ fun DrawerContent(
|
||||
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||
drawerTonalElevation = 0.dp,
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
Modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
ProfileContent(
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
@@ -159,16 +162,15 @@ fun DrawerContent(
|
||||
)
|
||||
|
||||
ListContent(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
drawerState,
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
drawerState,
|
||||
@@ -348,7 +350,7 @@ fun SendButton(onClick: () -> Unit) {
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
@@ -460,12 +462,7 @@ fun ListContent(
|
||||
val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Column(modifier) {
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.profile),
|
||||
icon = Route.Profile.icon,
|
||||
|
||||
@@ -759,6 +759,9 @@ fun RenderContentDVMThumb(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
// downloads user metadata to pre-load the NIP-65 relays.
|
||||
val user = baseNote.author?.live()?.metadata?.observeAsState()
|
||||
|
||||
val card = observeAppDefinition(appDefinitionNote = baseNote)
|
||||
|
||||
LeftPictureLayout(
|
||||
@@ -775,7 +778,16 @@ fun RenderContentDVMThumb(
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
}
|
||||
} ?: run { DisplayAuthorBanner(baseNote) }
|
||||
} ?: run {
|
||||
user?.value?.user?.let {
|
||||
BannerImage(
|
||||
it,
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTitleRow = {
|
||||
Text(
|
||||
|
||||
@@ -29,10 +29,10 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -40,32 +40,28 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -79,14 +75,16 @@ import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.chatAuthorImage
|
||||
import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
@@ -132,7 +130,6 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NormalChatNote(
|
||||
note: Note,
|
||||
@@ -145,6 +142,17 @@ fun NormalChatNote(
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
onWantsToEditDraft: (Note) -> Unit,
|
||||
) {
|
||||
val isLoggedInUser =
|
||||
remember(note.author) {
|
||||
accountViewModel.isLoggedUser(note.author)
|
||||
}
|
||||
|
||||
if (routeForLastRead != null) {
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt())
|
||||
}
|
||||
}
|
||||
|
||||
val drawAuthorInfo by
|
||||
remember(note) {
|
||||
derivedStateOf {
|
||||
@@ -162,13 +170,104 @@ fun NormalChatNote(
|
||||
}
|
||||
}
|
||||
|
||||
ChatBubbleLayout(
|
||||
isLoggedInUser = isLoggedInUser,
|
||||
innerQuote = innerQuote,
|
||||
isSimplified = accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED,
|
||||
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
|
||||
drawAuthorInfo = drawAuthorInfo,
|
||||
parentBackgroundColor = parentBackgroundColor,
|
||||
onClick = {
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
nav("Channel/${note.idHex}")
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onAuthorClick = {
|
||||
note.author?.let {
|
||||
nav("User/${it.pubkeyHex}")
|
||||
}
|
||||
},
|
||||
actionMenu = { popupExpanded, onDismiss ->
|
||||
NoteQuickActionMenu(
|
||||
note = note,
|
||||
popupExpanded = popupExpanded,
|
||||
onDismiss = onDismiss,
|
||||
onWantsToEditDraft = { onWantsToEditDraft(note) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
drawAuthorLine = {
|
||||
DrawAuthorInfo(
|
||||
note,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
},
|
||||
detailRow = {
|
||||
if (note.isDraft()) {
|
||||
DisplayDraftChat()
|
||||
}
|
||||
IncognitoBadge(note)
|
||||
ChatTimeAgo(note)
|
||||
RelayBadgesHorizontal(note, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
|
||||
ReplyReaction(
|
||||
baseNote = note,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSizeModifier = Size15Modifier,
|
||||
) {
|
||||
onWantsToReply(note)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
|
||||
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
}
|
||||
},
|
||||
) { backgroundBubbleColor ->
|
||||
MessageBubbleLines(
|
||||
note,
|
||||
innerQuote,
|
||||
backgroundBubbleColor,
|
||||
onWantsToReply,
|
||||
onWantsToEditDraft,
|
||||
canPreview,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatBubbleLayout(
|
||||
isLoggedInUser: Boolean,
|
||||
innerQuote: Boolean,
|
||||
isSimplified: Boolean,
|
||||
hasDetailsToShow: Boolean,
|
||||
drawAuthorInfo: Boolean,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
onClick: () -> Boolean,
|
||||
onAuthorClick: () -> Unit,
|
||||
actionMenu: @Composable (popupExpanded: Boolean, onDismiss: () -> Unit) -> Unit,
|
||||
detailRow: @Composable () -> Unit,
|
||||
drawAuthorLine: @Composable () -> Unit,
|
||||
inner: @Composable (MutableState<Color>) -> Unit,
|
||||
) {
|
||||
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
|
||||
val otherColors = MaterialTheme.colorScheme.subtleBorder
|
||||
val defaultBackground = MaterialTheme.colorScheme.background
|
||||
|
||||
val backgroundBubbleColor =
|
||||
remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
if (isLoggedInUser) {
|
||||
mutableStateOf(
|
||||
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
|
||||
)
|
||||
@@ -176,182 +275,176 @@ fun NormalChatNote(
|
||||
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
|
||||
}
|
||||
}
|
||||
|
||||
val alignment: Arrangement.Horizontal =
|
||||
remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
if (isLoggedInUser) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
|
||||
val shape: Shape =
|
||||
remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
ChatBubbleShapeMe
|
||||
} else {
|
||||
ChatBubbleShapeThem
|
||||
if (isLoggedInUser) {
|
||||
ChatBubbleShapeMe
|
||||
} else {
|
||||
ChatBubbleShapeThem
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
|
||||
horizontalArrangement = alignment,
|
||||
) {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
|
||||
|
||||
val showDetails =
|
||||
remember {
|
||||
mutableStateOf(
|
||||
if (isSimplified) {
|
||||
hasDetailsToShow
|
||||
} else {
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (routeForLastRead != null) {
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt())
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
|
||||
horizontalArrangement = alignment,
|
||||
) {
|
||||
val availableBubbleSize = remember { mutableIntStateOf(0) }
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
|
||||
|
||||
val showDetails =
|
||||
remember {
|
||||
mutableStateOf(
|
||||
if (accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED) {
|
||||
note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty()
|
||||
} else {
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val clickableModifier =
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
nav("Channel/${note.idHex}")
|
||||
} else {
|
||||
if (accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED) {
|
||||
showDetails.value = !showDetails.value
|
||||
}
|
||||
val clickableModifier =
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
if (!onClick()) {
|
||||
if (isSimplified) {
|
||||
showDetails.value = !showDetails.value
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier =
|
||||
modif2.onSizeChanged {
|
||||
if (availableBubbleSize.intValue != it.width) {
|
||||
availableBubbleSize.intValue = it.width
|
||||
}
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor.value,
|
||||
shape = shape,
|
||||
modifier = clickableModifier,
|
||||
) {
|
||||
RenderBubble(
|
||||
note,
|
||||
drawAuthorInfo,
|
||||
alignment,
|
||||
innerQuote,
|
||||
backgroundBubbleColor,
|
||||
onWantsToReply,
|
||||
onWantsToEditDraft,
|
||||
canPreview,
|
||||
availableBubbleSize,
|
||||
showDetails,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
onLongClick = { popupExpanded = true },
|
||||
)
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(
|
||||
note = note,
|
||||
popupExpanded = popupExpanded,
|
||||
onDismiss = { popupExpanded = false },
|
||||
onWantsToEditDraft = { onWantsToEditDraft(note) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2,
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor.value,
|
||||
shape = shape,
|
||||
modifier = clickableModifier,
|
||||
) {
|
||||
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
|
||||
if (drawAuthorInfo) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier = StdTopPadding.then(Modifier.clickable(onClick = onAuthorClick)),
|
||||
) {
|
||||
drawAuthorLine()
|
||||
}
|
||||
}
|
||||
|
||||
inner(backgroundBubbleColor)
|
||||
|
||||
if (showDetails.value) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat,
|
||||
) {
|
||||
detailRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actionMenu(popupExpanded) {
|
||||
popupExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderBubble(
|
||||
baseNote: Note,
|
||||
drawAuthorInfo: Boolean,
|
||||
alignment: Arrangement.Horizontal,
|
||||
innerQuote: Boolean,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
onWantsToEditDraft: (Note) -> Unit,
|
||||
canPreview: Boolean,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
showDetails: State<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val bubbleSize = remember { mutableIntStateOf(0) }
|
||||
|
||||
val bubbleModifier =
|
||||
private fun BubblePreview() {
|
||||
val backgroundBubbleColor =
|
||||
remember {
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
if (bubbleSize.intValue != it.width) {
|
||||
bubbleSize.intValue = it.width
|
||||
}
|
||||
}
|
||||
mutableStateOf<Color>(Color.Transparent)
|
||||
}
|
||||
|
||||
Column(modifier = bubbleModifier) {
|
||||
MessageBubbleLines(
|
||||
drawAuthorInfo,
|
||||
baseNote,
|
||||
alignment,
|
||||
availableBubbleSize,
|
||||
innerQuote,
|
||||
backgroundBubbleColor,
|
||||
bubbleSize,
|
||||
onWantsToReply,
|
||||
onWantsToEditDraft,
|
||||
canPreview,
|
||||
showDetails,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
Column {
|
||||
ChatBubbleLayout(
|
||||
isLoggedInUser = false,
|
||||
innerQuote = false,
|
||||
isSimplified = false,
|
||||
hasDetailsToShow = true,
|
||||
drawAuthorInfo = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
onClick = { false },
|
||||
onAuthorClick = {},
|
||||
actionMenu = { popupExpanded, onDismiss ->
|
||||
},
|
||||
drawAuthorLine = {
|
||||
UserDisplayNameLayout(
|
||||
picture = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(Size20dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
name = {
|
||||
Text("Me", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
)
|
||||
},
|
||||
detailRow = { Text("Relays and Actions") },
|
||||
) { backgroundBubbleColor ->
|
||||
Text("This is my note")
|
||||
}
|
||||
|
||||
ChatBubbleLayout(
|
||||
isLoggedInUser = true,
|
||||
innerQuote = false,
|
||||
isSimplified = false,
|
||||
hasDetailsToShow = true,
|
||||
drawAuthorInfo = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
onClick = { false },
|
||||
onAuthorClick = {},
|
||||
actionMenu = { popupExpanded, onDismiss ->
|
||||
},
|
||||
drawAuthorLine = {
|
||||
UserDisplayNameLayout(
|
||||
picture = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(Size20dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
name = {
|
||||
Text("Me", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
)
|
||||
},
|
||||
detailRow = { Text("Relays and Actions") },
|
||||
) { backgroundBubbleColor ->
|
||||
Text("This is a very long long loong note")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubbleLines(
|
||||
drawAuthorInfo: Boolean,
|
||||
baseNote: Note,
|
||||
alignment: Arrangement.Horizontal,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
innerQuote: Boolean,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
bubbleSize: MutableState<Int>,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
onWantsToEditDraft: (Note) -> Unit,
|
||||
canPreview: Boolean,
|
||||
showDetails: State<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
if (drawAuthorInfo) {
|
||||
DrawAuthorInfo(
|
||||
baseNote,
|
||||
alignment,
|
||||
accountViewModel.settings.showProfilePictures.value,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (baseNote.event !is DraftEvent) {
|
||||
RenderReplyRow(
|
||||
note = baseNote,
|
||||
@@ -374,41 +467,6 @@ private fun MessageBubbleLines(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
if (showDetails.value) {
|
||||
ConstrainedStatusRow(
|
||||
bubbleSize = bubbleSize,
|
||||
availableBubbleSize = availableBubbleSize,
|
||||
firstColumn = {
|
||||
if (baseNote.isDraft()) {
|
||||
DisplayDraftChat()
|
||||
}
|
||||
IncognitoBadge(baseNote)
|
||||
ChatTimeAgo(baseNote)
|
||||
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
},
|
||||
secondColumn = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
|
||||
ReplyReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSizeModifier = Size15Modifier,
|
||||
) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -536,43 +594,9 @@ private fun RenderDraftEvent(
|
||||
|
||||
@Composable
|
||||
private fun ConstrainedStatusRow(
|
||||
bubbleSize: MutableState<Int>,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
firstColumn: @Composable () -> Unit,
|
||||
secondColumn: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier =
|
||||
with(LocalDensity.current) {
|
||||
Modifier
|
||||
.padding(top = Size5dp)
|
||||
.height(Size20dp)
|
||||
.widthIn(
|
||||
bubbleSize.value.toDp(),
|
||||
availableBubbleSize.value.toDp(),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat,
|
||||
) {
|
||||
firstColumn()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat,
|
||||
) {
|
||||
secondColumn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -581,10 +605,7 @@ fun IncognitoBadge(baseNote: Note) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito),
|
||||
null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(14.dp),
|
||||
modifier = incognitoIconModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
@@ -592,10 +613,7 @@ fun IncognitoBadge(baseNote: Note) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito_off),
|
||||
null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(14.dp),
|
||||
modifier = incognitoIconModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
@@ -642,6 +660,7 @@ private fun RenderRegularTextNote(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -655,6 +674,7 @@ private fun RenderRegularTextNote(
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -707,68 +727,56 @@ private fun RenderCreateChannelNote(note: Note) {
|
||||
@Composable
|
||||
private fun DrawAuthorInfo(
|
||||
baseNote: Note,
|
||||
alignment: Arrangement.Horizontal,
|
||||
loadProfilePicture: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
baseNote.author?.let {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = Size10dp)
|
||||
.clickable {
|
||||
nav("User/${baseNote.author?.pubkeyHex}")
|
||||
},
|
||||
) {
|
||||
WatchAndDisplayUser(it, loadProfilePicture, accountViewModel, nav)
|
||||
}
|
||||
WatchAndDisplayUser(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserDisplayNameLayout(
|
||||
picture: @Composable () -> Unit,
|
||||
name: @Composable () -> Unit,
|
||||
) {
|
||||
Box(chatAuthorBox, contentAlignment = Alignment.TopEnd) {
|
||||
picture()
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
name()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAndDisplayUser(
|
||||
author: User,
|
||||
loadProfilePicture: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val userState by author.live().userMetadataInfo.observeAsState()
|
||||
|
||||
Box(chatAuthorBox, contentAlignment = Alignment.TopEnd) {
|
||||
InnerUserPicture(
|
||||
userHex = author.pubkeyHex,
|
||||
userPicture = userState?.picture,
|
||||
userName = userState?.bestName(),
|
||||
size = Size20dp,
|
||||
modifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
UserDisplayNameLayout(
|
||||
picture = {
|
||||
InnerUserPicture(
|
||||
userHex = author.pubkeyHex,
|
||||
userPicture = userState?.picture,
|
||||
userName = userState?.bestName(),
|
||||
size = Size20dp,
|
||||
modifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
|
||||
ObserveAndDisplayFollowingMark(author.pubkeyHex, Size5dp, accountViewModel)
|
||||
}
|
||||
|
||||
if (userState != null) {
|
||||
DisplayMessageUsername(userState?.bestName() ?: author.pubkeyDisplayHex(), userState?.tags ?: EmptyTagList)
|
||||
} else {
|
||||
DisplayMessageUsername(author.pubkeyDisplayHex(), EmptyTagList)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserIcon(
|
||||
pubkeyHex: String,
|
||||
userProfilePicture: String?,
|
||||
loadProfilePicture: Boolean,
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubkeyHex,
|
||||
model = userProfilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
modifier = chatAuthorImage,
|
||||
ObserveAndDisplayFollowingMark(author.pubkeyHex, Size5dp, accountViewModel)
|
||||
},
|
||||
name = {
|
||||
if (userState != null) {
|
||||
DisplayMessageUsername(userState?.bestName() ?: author.pubkeyDisplayHex(), userState?.tags ?: EmptyTagList)
|
||||
} else {
|
||||
DisplayMessageUsername(author.pubkeyDisplayHex(), EmptyTagList)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -777,7 +785,6 @@ private fun DisplayMessageUsername(
|
||||
userDisplayName: String,
|
||||
userTags: ImmutableListOfLists<String>,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CreateTextWithEmoji(
|
||||
text = userDisplayName,
|
||||
tags = userTags,
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
@@ -176,8 +177,10 @@ fun LoadOts(
|
||||
(earliestDate as? GenericLoadable.Loaded)?.let {
|
||||
whenConfirmed(it.loaded)
|
||||
} ?: run {
|
||||
val account = accountViewModel.account.saveable.observeAsState()
|
||||
if (account.value?.account?.hasPendingAttestations(note) == true) {
|
||||
val pendingAttestations by accountViewModel.account.pendingAttestations.collectAsStateWithLifecycle()
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
|
||||
if (pendingAttestations[id] != null) {
|
||||
whenPending()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
@@ -174,6 +175,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.VideoEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
@@ -303,8 +305,9 @@ fun AcceptableNote(
|
||||
)
|
||||
}
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel)
|
||||
is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel)
|
||||
else ->
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -562,11 +565,13 @@ fun NoteBody(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
val noteEvent = baseNote.event
|
||||
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
|
||||
if (zapSplits && noteEvent != null) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
|
||||
if (!makeItShort) {
|
||||
val noteEvent = baseNote.event
|
||||
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
|
||||
if (zapSplits && noteEvent != null) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,8 +590,8 @@ private fun RenderNoteRow(
|
||||
val noteEvent = baseNote.event
|
||||
when (noteEvent) {
|
||||
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
is AudioTrackEvent -> RenderAudioTrack(baseNote, accountViewModel, nav)
|
||||
is AudioHeaderEvent -> RenderAudioHeader(baseNote, accountViewModel, nav)
|
||||
is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav)
|
||||
is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav)
|
||||
is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
|
||||
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
@@ -704,10 +709,10 @@ private fun RenderNoteRow(
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, accountViewModel)
|
||||
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, accountViewModel)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
@@ -975,14 +980,16 @@ fun FirstUserInfoRow(
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor)
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
BoostedMark()
|
||||
} else if (isCommunityPost) {
|
||||
if (isCommunityPost) {
|
||||
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
BoostedMark()
|
||||
}
|
||||
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
|
||||
DisplayEditStatus(it)
|
||||
|
||||
@@ -179,6 +179,7 @@ private fun OptionNote(
|
||||
pollViewModel = pollViewModel,
|
||||
nonClickablePrepend = {
|
||||
RenderOptionAfterVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
color,
|
||||
canPreview,
|
||||
@@ -217,6 +218,7 @@ private fun OptionNote(
|
||||
|
||||
@Composable
|
||||
private fun RenderOptionAfterVote(
|
||||
baseNote: Note,
|
||||
poolOption: PollOption,
|
||||
color: Color,
|
||||
canPreview: Boolean,
|
||||
@@ -276,7 +278,8 @@ private fun RenderOptionAfterVote(
|
||||
Modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
poolOption.descriptor,
|
||||
baseNote.idHex + poolOption.descriptor,
|
||||
baseNote.toNostrUri(),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -100,6 +100,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
@@ -166,18 +167,12 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
val relayUrl =
|
||||
walletConnectRelay.text
|
||||
.ifBlank { null }
|
||||
?.let {
|
||||
var addedWSS =
|
||||
if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it
|
||||
if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1)
|
||||
|
||||
addedWSS
|
||||
}
|
||||
?.let { RelayUrlFormatter.normalize(it) }
|
||||
|
||||
val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) }
|
||||
|
||||
if (pubkeyHex != null) {
|
||||
account?.changeZapPaymentRequest(
|
||||
account.changeZapPaymentRequest(
|
||||
Nip47WalletConnect.Nip47URI(
|
||||
pubkeyHex,
|
||||
relayUrl,
|
||||
|
||||
@@ -236,6 +236,7 @@ fun RenderAppDefinition(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -60,18 +60,20 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun RenderAudioTrack(
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val noteEvent = note.event as? AudioTrackEvent ?: return
|
||||
|
||||
AudioTrackHeader(noteEvent, note, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioTrackHeader(
|
||||
noteEvent: AudioTrackEvent,
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -140,21 +142,26 @@ fun AudioTrackHeader(
|
||||
cover?.let { cover ->
|
||||
LoadThumbAndThenVideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = noteEvent.subject(),
|
||||
thumbUri = cover,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
nostrUriCallback = "nostr:${note.toNEvent()}",
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
?: VideoView(
|
||||
} ?: run {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,18 +171,20 @@ fun AudioTrackHeader(
|
||||
@Composable
|
||||
fun RenderAudioHeader(
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val noteEvent = note.event as? AudioHeaderEvent ?: return
|
||||
|
||||
AudioHeader(noteEvent, note, accountViewModel, nav)
|
||||
AudioHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioHeader(
|
||||
noteEvent: AudioHeaderEvent,
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -195,10 +204,12 @@ fun AudioHeader(
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
waveform = waveform,
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = note.toNostrUri(),
|
||||
)
|
||||
@@ -220,6 +231,7 @@ fun AudioHeader(
|
||||
tags = tags,
|
||||
backgroundColor = background,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
fun FileHeaderDisplay(
|
||||
note: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
@@ -49,8 +50,9 @@ fun FileHeaderDisplay(
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = RichTextParser.isImageUrl(fullUrl)
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
@@ -61,6 +63,7 @@ fun FileHeaderDisplay(
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -71,6 +74,7 @@ fun FileHeaderDisplay(
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -80,6 +84,7 @@ fun FileHeaderDisplay(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import java.io.File
|
||||
fun FileStorageHeaderDisplay(
|
||||
baseNote: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||
@@ -49,7 +50,7 @@ fun FileStorageHeaderDisplay(
|
||||
|
||||
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
|
||||
if (contentNote != null) {
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, isFiniteHeight, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +60,7 @@ private fun ObserverAndRenderNIP95(
|
||||
header: Note,
|
||||
content: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
|
||||
@@ -110,6 +112,7 @@ private fun ObserverAndRenderNIP95(
|
||||
ZoomableContentView(
|
||||
content = it,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ private fun RenderGitPatchEvent(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -291,6 +292,7 @@ private fun RenderGitIssueEvent(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -100,15 +100,16 @@ fun DisplayHighlight(
|
||||
}
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
quote,
|
||||
content = quote,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft,
|
||||
Modifier.fillMaxWidth(),
|
||||
EmptyTagList,
|
||||
backgroundColor,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
id = quote,
|
||||
accountViewModel,
|
||||
nav,
|
||||
callbackUri = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)
|
||||
|
||||
@@ -152,10 +152,12 @@ fun RenderLiveActivityEventInner(
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = subject,
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
|
||||
)
|
||||
|
||||
+1
@@ -138,6 +138,7 @@ fun RenderNIP90ContentDiscoveryResponse(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
@@ -65,22 +64,18 @@ fun RenderPoll(
|
||||
val showReply by
|
||||
remember(note) {
|
||||
derivedStateOf {
|
||||
noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
!makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
}
|
||||
}
|
||||
|
||||
if (showReply) {
|
||||
val replyingDirectlyTo =
|
||||
remember(note) {
|
||||
if (noteEvent is BaseTextNoteEvent) {
|
||||
val replyingTo = noteEvent.replyingToAddressOrEvent()
|
||||
if (replyingTo != null) {
|
||||
val newNote = accountViewModel.getNoteIfExists(replyingTo)
|
||||
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
|
||||
newNote
|
||||
} else {
|
||||
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
|
||||
}
|
||||
val replyingTo = noteEvent.replyingToAddressOrEvent()
|
||||
if (replyingTo != null) {
|
||||
val newNote = accountViewModel.getNoteIfExists(replyingTo)
|
||||
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
|
||||
newNote
|
||||
} else {
|
||||
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
|
||||
}
|
||||
@@ -116,6 +111,7 @@ fun RenderPoll(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -115,6 +115,7 @@ fun RenderPrivateMessage(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -144,6 +145,7 @@ fun RenderPrivateMessage(
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ fun RenderReport(
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
||||
@@ -149,6 +149,7 @@ fun RenderTextEvent(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -119,6 +119,7 @@ fun RenderTextModificationEvent(
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ fun VideoDisplay(
|
||||
makeItShort: Boolean,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: MutableState<Color>,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -84,8 +85,9 @@ fun VideoDisplay(
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifBlank { null } ?: event.alt()
|
||||
val isImage = RichTextParser.isImageUrl(fullUrl)
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
@@ -96,6 +98,7 @@ fun VideoDisplay(
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -106,6 +109,7 @@ fun VideoDisplay(
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
artworkUri = event.thumb() ?: event.image(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -143,6 +147,7 @@ fun VideoDisplay(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -169,6 +174,7 @@ fun VideoDisplay(
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.VideoEvent
|
||||
|
||||
@Composable
|
||||
fun JustVideoDisplay(
|
||||
note: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = (note.event as? VideoEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ private fun FeedLoaded(
|
||||
NoteCompose(
|
||||
item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = state.showHidden.value,
|
||||
quotesLeft = 3,
|
||||
|
||||
@@ -504,17 +504,17 @@ fun NoteMaster(
|
||||
nav = nav,
|
||||
)
|
||||
} else if (noteEvent is VideoEvent) {
|
||||
VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav)
|
||||
VideoDisplay(baseNote, false, true, backgroundColor, false, accountViewModel, nav)
|
||||
} else if (noteEvent is FileHeaderEvent) {
|
||||
FileHeaderDisplay(baseNote, true, accountViewModel)
|
||||
FileHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
|
||||
FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
} else if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is AudioTrackEvent) {
|
||||
AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, baseNote, false, accountViewModel, nav)
|
||||
} else if (noteEvent is AudioHeaderEvent) {
|
||||
AudioHeader(noteEvent, baseNote, accountViewModel, nav)
|
||||
AudioHeader(noteEvent, baseNote, false, accountViewModel, nav)
|
||||
} else if (noteEvent is CommunityPostApprovalEvent) {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
|
||||
@@ -125,6 +125,8 @@ import kotlin.time.measureTimedValue
|
||||
val params: Array<out String>? = null,
|
||||
) : ToastMsg()
|
||||
|
||||
@Immutable class ThrowableToastMsg(val titleResId: Int, val msg: String? = null, val throwable: Throwable) : ToastMsg()
|
||||
|
||||
@Stable
|
||||
class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao {
|
||||
val accountLiveData: LiveData<AccountState> = account.live.map { it }
|
||||
@@ -163,6 +165,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) }
|
||||
}
|
||||
|
||||
fun toast(
|
||||
titleResId: Int,
|
||||
message: String?,
|
||||
throwable: Throwable,
|
||||
) {
|
||||
viewModelScope.launch { toasts.emit(ThrowableToastMsg(titleResId, message, throwable)) }
|
||||
}
|
||||
|
||||
fun toast(
|
||||
titleResId: Int,
|
||||
resourceId: Int,
|
||||
|
||||
@@ -743,6 +743,7 @@ fun ShowVideoStreaming(
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -86,7 +86,7 @@ fun ChatroomListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val windowSizeClass = accountViewModel.settings.windowSizeClass.value
|
||||
val windowSizeClass by accountViewModel.settings.windowSizeClass
|
||||
|
||||
val twoPane by remember {
|
||||
derivedStateOf {
|
||||
@@ -104,7 +104,7 @@ fun ChatroomListScreen(
|
||||
ChatroomListTwoPane(
|
||||
knownFeedViewModel = knownFeedViewModel,
|
||||
newFeedViewModel = newFeedViewModel,
|
||||
widthSizeClass = windowSizeClass.widthSizeClass,
|
||||
widthSizeClass = windowSizeClass!!.widthSizeClass,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -43,6 +43,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -621,17 +623,11 @@ private fun ReportsTabHeader(baseUser: User) {
|
||||
|
||||
@Composable
|
||||
private fun FollowedTagsTabHeader(baseUser: User) {
|
||||
var usertags by remember { mutableIntStateOf(0) }
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = baseUser) {
|
||||
launch(Dispatchers.IO) {
|
||||
val contactList = baseUser.latestContactList
|
||||
|
||||
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
|
||||
|
||||
if (newTags != usertags) {
|
||||
usertags = newTags
|
||||
}
|
||||
val usertags by remember(baseUser) {
|
||||
derivedStateOf {
|
||||
userState?.user?.latestContactList?.countFollowTags() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,17 +1536,24 @@ fun TabFollowedTags(
|
||||
account: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val items =
|
||||
remember(baseUser) {
|
||||
baseUser.latestContactList?.unverifiedFollowTagSet()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight().padding(vertical = 0.dp)) {
|
||||
baseUser.latestContactList?.let {
|
||||
it.unverifiedFollowTagSet().forEach { hashtag ->
|
||||
HashtagHeader(
|
||||
tag = hashtag,
|
||||
account = account,
|
||||
onClick = { nav("Hashtag/$hashtag") },
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
items?.let {
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, hashtag ->
|
||||
HashtagHeader(
|
||||
tag = hashtag,
|
||||
account = account,
|
||||
onClick = { nav("Hashtag/$hashtag") },
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||
@@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.VideoEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -325,12 +327,14 @@ private fun RenderVideoOrPictureNote(
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize(1f), verticalArrangement = Arrangement.Center) {
|
||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
val noteEvent = remember { note.event }
|
||||
if (noteEvent is FileHeaderEvent) {
|
||||
FileHeaderDisplay(note, false, accountViewModel)
|
||||
FileHeaderDisplay(note, false, true, accountViewModel)
|
||||
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||
FileStorageHeaderDisplay(note, false, accountViewModel)
|
||||
FileStorageHeaderDisplay(note, false, true, accountViewModel)
|
||||
} else if (noteEvent is VideoEvent) {
|
||||
JustVideoDisplay(note, false, true, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ val Size35dp = 35.dp
|
||||
val Size40dp = 40.dp
|
||||
val Size55dp = 55.dp
|
||||
val Size75dp = 75.dp
|
||||
val Size110dp = 110.dp
|
||||
val Size165dp = 165.dp
|
||||
|
||||
val HalfEndPadding = Modifier.padding(end = 5.dp)
|
||||
val HalfStartPadding = Modifier.padding(start = 5.dp)
|
||||
@@ -108,6 +110,7 @@ val StdPadding = Modifier.padding(10.dp)
|
||||
val BigPadding = Modifier.padding(15.dp)
|
||||
|
||||
val RowColSpacing = Arrangement.spacedBy(3.dp)
|
||||
val RowColSpacing5dp = Arrangement.spacedBy(5.dp)
|
||||
|
||||
val HalfHorzPadding = Modifier.padding(horizontal = 5.dp)
|
||||
val HalfVertPadding = Modifier.padding(vertical = 5.dp)
|
||||
@@ -236,9 +239,19 @@ val chatAuthorBox = Modifier.size(20.dp)
|
||||
val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape)
|
||||
val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp)
|
||||
|
||||
val messageDetailsModifier = Modifier.height(Size25dp)
|
||||
val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp)
|
||||
|
||||
val inlinePlaceholder =
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
)
|
||||
|
||||
val incognitoIconModifier =
|
||||
Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(14.dp)
|
||||
|
||||
val hashVerifierMark = Modifier.width(40.dp).height(40.dp).padding(10.dp)
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<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="save_to_gallery">Uložit do galerie</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>
|
||||
@@ -405,6 +406,7 @@
|
||||
<string name="languages">Jazyky</string>
|
||||
<string name="tags">Tagy</string>
|
||||
<string name="posting_policy">Politika příspěvků</string>
|
||||
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
|
||||
<string name="message_length">Délka zprávy</string>
|
||||
<string name="subscriptions">Předplatné</string>
|
||||
<string name="filters">Filtry</string>
|
||||
@@ -498,6 +500,7 @@
|
||||
<string name="load_image_description">Kdy načíst obrázek</string>
|
||||
<string name="copy_to_clipboard">Kopírovat do schránky</string>
|
||||
<string name="copy_npub_to_clipboard">Kopírovat npub do schránky</string>
|
||||
<string name="share_or_save">Sdílet nebo Uložit</string>
|
||||
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
|
||||
<string name="created_at">Vytvořeno</string>
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<string name="website_url">Website-URL</string>
|
||||
<string name="ln_address">LN-Adresse</string>
|
||||
<string name="ln_url_outdated">LN-URL (veraltet)</string>
|
||||
<string name="save_to_gallery">In die Galerie abspeichern</string>
|
||||
<string name="image_saved_to_the_gallery">Bild in der Gal
|
||||
|
||||
erie gespeichert</string>
|
||||
@@ -410,6 +411,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="languages">Sprachen</string>
|
||||
<string name="tags">Tags</string>
|
||||
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
|
||||
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
|
||||
<string name="message_length">Nachrichtenlänge</string>
|
||||
<string name="subscriptions">Abonnements</string>
|
||||
<string name="filters">Filter</string>
|
||||
@@ -503,6 +505,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="load_image_description">Wann Bilder geladen werden sollen</string>
|
||||
<string name="copy_to_clipboard">In Zwischenablage kopieren</string>
|
||||
<string name="copy_npub_to_clipboard">Npub in Zwischenablage kopieren</string>
|
||||
<string name="share_or_save">Teilen oder Speichern</string>
|
||||
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
|
||||
<string name="created_at">Erstellt am</string>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="point_to_the_qr_code">क्यूआर क्रमचित्र के अभिमुख करें</string>
|
||||
<string name="show_qr">क्यूआर क्रमचित्र दिखाएँ</string>
|
||||
<string name="point_to_the_qr_code">क्यूआर॰ क्रमचित्र के अभिमुख करें</string>
|
||||
<string name="show_qr">क्यूआर॰ क्रमचित्र दिखाएँ</string>
|
||||
<string name="profile_image">परिचय चित्र</string>
|
||||
<string name="your_profile_image">आपका परिचय चित्र</string>
|
||||
<string name="scan_qr">क्यूआर क्रमचित्र को परखें</string>
|
||||
<string name="scan_qr">क्यूआर॰ क्रमचित्र को परखें</string>
|
||||
<string name="show_anyway">फिर भी दिखाएँ</string>
|
||||
<string name="post_was_hidden">इस प्रकाशित पत्र को छिपाया गया क्योंकि इसमें आपके आच्छाद्य उपयोगकर्ता अथवा शब्द उल्लेखित हैं</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">प्रकाशित पत्र को मौन किया गया अथवा सूचित किया गया इनके द्वारा</string>
|
||||
<string name="post_not_found">घटना पुष्ट किया जा रहा है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं</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>
|
||||
@@ -48,7 +48,7 @@
|
||||
<string name="zaps">ज्साप</string>
|
||||
<string name="view_count">दृष्ट संख्या</string>
|
||||
<string name="boost">उद्धृत करें</string>
|
||||
<string name="boosted">उद्धृत किया गया</string>
|
||||
<string name="boosted">उद्धृत</string>
|
||||
<string name="edited">सम्पादित</string>
|
||||
<string name="edited_number">सम्पादन #%1$s</string>
|
||||
<string name="original">मूल</string>
|
||||
@@ -63,7 +63,7 @@
|
||||
<string name="profile_banner">परिचय पृष्ठ चौडाचित्र</string>
|
||||
<string name="payment_successful">भुगतान सफल</string>
|
||||
<string name="error_parsing_error_message">अपक्रम संदेश परखने में अपक्रम</string>
|
||||
<string name="following">" अनुचरण चालू"</string>
|
||||
<string name="following">" का अनुचरण"</string>
|
||||
<string name="followers">" अनुचर"</string>
|
||||
<string name="profile">परिचय</string>
|
||||
<string name="security_filters">सुरक्षा छलनियाँ</string>
|
||||
@@ -86,12 +86,12 @@
|
||||
<string name="about_us">"हमारा परिचय.. "</string>
|
||||
<string name="what_s_on_your_mind">आपके मन में क्या है?</string>
|
||||
<string name="post">पत्र प्रकाशन</string>
|
||||
<string name="save">अभिलेखित करें</string>
|
||||
<string name="save">अभिलेखन करें</string>
|
||||
<string name="create">बनाएँ</string>
|
||||
<string name="cancel">निरस्त करें</string>
|
||||
<string name="failed_to_upload_the_image">चित्र आरोहण असफल</string>
|
||||
<string name="relay_address">पुनःप्रसारक पता</string>
|
||||
<string name="posts">पत्र</string>
|
||||
<string name="posts">प्रकाशित पत्र</string>
|
||||
<string name="bytes">अष्टक</string>
|
||||
<string name="errors">अपक्रम</string>
|
||||
<string name="errors_description">संयोजन अपक्रमों की संख्या इस सत्र में</string>
|
||||
@@ -104,7 +104,7 @@
|
||||
<string name="display_name">प्रदर्शन नाम</string>
|
||||
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
|
||||
<string name="my_awesome_name">उष्ट्रपक्षी मक्बढिया</string>
|
||||
<string name="welcome">स्वागतम उष्ट्रपक्षी</string>
|
||||
<string name="welcome">स्वागतम उष्ट्रपक्षी!</string>
|
||||
<string name="username">उपयोगकर्ता नाम</string>
|
||||
<string name="my_username">मेरा उपयोगकर्ता नाम</string>
|
||||
<string name="about_me">मेरा परिचय</string>
|
||||
@@ -113,8 +113,9 @@
|
||||
<string name="website_url">जालस्थान पता</string>
|
||||
<string name="ln_address">लै॰जाल पता</string>
|
||||
<string name="ln_url_outdated">लै॰जाल पता (पुराना)</string>
|
||||
<string name="image_saved_to_the_gallery">चित्र को चित्रालय क्रमक में रख दिया गया</string>
|
||||
<string name="failed_to_save_the_image">चित्र को रखने में असफल</string>
|
||||
<string name="save_to_gallery">चित्रालय में अभिलेखन करें</string>
|
||||
<string name="image_saved_to_the_gallery">चित्र का अभिलेखन किया गया चित्रालय क्रमक में</string>
|
||||
<string name="failed_to_save_the_image">चित्र का अभिलेखन असफल</string>
|
||||
<string name="upload_image">चित्र आरोहण</string>
|
||||
<string name="uploading">आरोहण चल रहा है…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
|
||||
@@ -127,19 +128,19 @@
|
||||
<string name="new_requests">नये अनुरोध</string>
|
||||
<string name="blocked_users">बाधित उपयोगकर्ता</string>
|
||||
<string name="new_threads">नये सूत्र</string>
|
||||
<string name="conversations">संवाद</string>
|
||||
<string name="conversations">सम्वाद</string>
|
||||
<string name="notes">टीकाएँ</string>
|
||||
<string name="replies">उत्तरें</string>
|
||||
<string name="follows">"अनुचरण कर्ता है"</string>
|
||||
<string name="replies">प्रतिवचन</string>
|
||||
<string name="follows">"का अनुचरण"</string>
|
||||
<string name="reports">"सूचनाएँ"</string>
|
||||
<string name="more_options">अधिक विकल्प</string>
|
||||
<string name="relays">" पुनःप्रसारक"</string>
|
||||
<string name="website">जालस्थान</string>
|
||||
<string name="lightning_address">लैटनिंग पता</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">NSEC विभेदक (आपका गुप्त पारणशब्द) का अनुकरण करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">एनसेक॰ विभेदक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
|
||||
<string name="copy_private_key_to_the_clipboard">गुप्त कुंचिका की अनुकृति करें टाँकाफलक में</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">ख्याप्य कुंचिका की अनुकृति करता है टाँकाफलक में बाँटने के लिए</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">ख्याप्य कुंचिका (NPub) का अनुकरण करें टाँकाफलक में</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">ख्याप्य कुंचिका (एनपुब॰) की अनुकृति करें टाँकाफलक में</string>
|
||||
<string name="send_a_direct_message">सीधा संदेश भेजें</string>
|
||||
<string name="edits_the_user_s_metadata">उपयोगकर्ता के उपतथ्य का सम्पादन करता है</string>
|
||||
<string name="follow">अनुचरण करें</string>
|
||||
@@ -147,10 +148,10 @@
|
||||
<string name="unblock">बाधा हटाएँ</string>
|
||||
<string name="copy_user_id">उपयोगकर्ता विभेदक की अनुकृति करें</string>
|
||||
<string name="unblock_user">उपयोगकर्ता बाधा हटाएँ</string>
|
||||
<string name="npub_hex_username">"npub, उपयोगकर्ता नाम, लेख"</string>
|
||||
<string name="npub_hex_username">"एनपुब॰, उपयोगकर्ता नाम, लेख"</string>
|
||||
<string name="clear">रिक्त करें</string>
|
||||
<string name="app_logo">क्रमक चिह्न</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec.. अथवा npub..</string>
|
||||
<string name="nsec_npub_hex_private_key">एनसेक॰.. अथवा एनपुब॰..</string>
|
||||
<string name="ncryptsec_password">कुंचिका को खोलने के लिए पारणशब्द</string>
|
||||
<string name="show_password">पारणशब्द दिखाएँ</string>
|
||||
<string name="hide_password">पारणशब्द छिपाएँ</string>
|
||||
@@ -170,9 +171,9 @@
|
||||
<string name="already_have_an_account">क्या नोस्ट्र लेखा पहले से है?</string>
|
||||
<string name="create_a_new_account">नया लेखा बनाएँ</string>
|
||||
<string name="generate_a_new_key">नयी कुंचिका बनाएँ</string>
|
||||
<string name="loading_feed">सूचनावली प्राप्त किया जा रहा है</string>
|
||||
<string name="loading_account">लेखा प्राप्त किया जा रहा है</string>
|
||||
<string name="error_loading_replies">"उत्तरों को प्राप्त करने में अपक्रम "</string>
|
||||
<string name="loading_feed">सूचनावली प्राप्त की जा रही है</string>
|
||||
<string name="loading_account">लेखा जानकारी प्राप्त की जा रही है</string>
|
||||
<string name="error_loading_replies">"प्रतिवचनों को प्राप्त करने में अपक्रम : "</string>
|
||||
<string name="try_again">पुनः प्रयास करें</string>
|
||||
<string name="feed_is_empty">सूचनावली रिक्त है।</string>
|
||||
<string name="refresh">नवीकरण</string>
|
||||
@@ -185,7 +186,7 @@
|
||||
<string name="leave">निर्गमन</string>
|
||||
<string name="unfollow">अनुचरण ना करें</string>
|
||||
<string name="channel_created">प्रणाली बनायी गयी</string>
|
||||
<string name="channel_information_changed_to">"प्रणाली सूचना बना दिया किया गया इसे"</string>
|
||||
<string name="channel_information_changed_to">"प्रणाली जानकारी परिवर्तित की गयी"</string>
|
||||
<string name="public_chat">सार्वजनिक चर्चा</string>
|
||||
<string name="posts_received">पत्र प्राप्त</string>
|
||||
<string name="remove">हटाएँ</string>
|
||||
@@ -198,9 +199,9 @@
|
||||
<string name="nip_05">नोस्ट्र पता</string>
|
||||
<string name="never">कभी नहीं</string>
|
||||
<string name="now">अभी</string>
|
||||
<string name="h">ह</string>
|
||||
<string name="m">म</string>
|
||||
<string name="d">द</string>
|
||||
<string name="h"> घं॰</string>
|
||||
<string name="m"> मि॰</string>
|
||||
<string name="d"> दि॰</string>
|
||||
<string name="nudity">नग्नता</string>
|
||||
<string name="profanity_hateful_speech">अपशब्द / द्वेषपूर्ण भाषण</string>
|
||||
<string name="report_hateful_speech">द्वेषपूर्ण भाषण की सूचना दें</string>
|
||||
@@ -208,10 +209,10 @@
|
||||
<string name="others">अन्य</string>
|
||||
<string name="mark_all_known_as_read">सब ज्ञात को पढ लिया चिह्नित करें</string>
|
||||
<string name="mark_all_new_as_read">सब नये को पढ लिया चिह्नित करें</string>
|
||||
<string name="mark_all_as_read">सब पढ लिया चिह्नित करें</string>
|
||||
<string name="mark_all_as_read">सब को पढ लिया चिह्नित करें</string>
|
||||
<string name="backup_keys">कुंचिकाएँ सुरक्षित रखें</string>
|
||||
<string name="account_backup_tips2_md" tools:ignore="Typos">## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव
|
||||
\n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशन कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा।
|
||||
\n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा।
|
||||
\n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो।
|
||||
\n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे।
|
||||
\n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं।
|
||||
@@ -220,7 +221,7 @@
|
||||
\n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते।
|
||||
</string>
|
||||
<string name="failed_to_encrypt_key">आपकी निजी कुंचिका का रहस्यीकरण असफल</string>
|
||||
<string name="secret_key_copied_to_clipboard">गुप्त कुंचिका (nsec) का अनुकरण टाँकाफलक में डाला गया।</string>
|
||||
<string name="secret_key_copied_to_clipboard">गुप्त कुंचिका (एनसेक॰) की अनुकृति टाँकाफलक में किया गया।</string>
|
||||
<string name="copy_my_secret_key">मेरी गुप्त कुंचिका की अनुकृति करें</string>
|
||||
<string name="encrypt_and_copy_my_secret_key">मेरी गुप्त कुंचिका का रहस्यीकरण तथा अनुकृति करें</string>
|
||||
<string name="biometric_authentication_failed">प्रमाणीकरण असफल</string>
|
||||
@@ -277,21 +278,21 @@
|
||||
<string name="report_dialog_post_report_btn">सूचना प्रकाशित करें</string>
|
||||
<string name="report_dialog_title">बाधित करें तथा सूचना दें</string>
|
||||
<string name="block_only">बाधित करें</string>
|
||||
<string name="bookmarks">स्मारकचिह्न</string>
|
||||
<string name="bookmarks">स्मर्तव्य</string>
|
||||
<string name="drafts">पाण्डुलिपियाँ</string>
|
||||
<string name="private_bookmarks">निजी स्मारकचिह्नित वस्तु</string>
|
||||
<string name="public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु</string>
|
||||
<string name="add_to_private_bookmarks">निजी स्मारकचिह्नित वस्तु सूची में जोडें</string>
|
||||
<string name="add_to_public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु सूची में जोडें</string>
|
||||
<string name="remove_from_private_bookmarks">निजी स्मारकचिह्नित वस्तु सूची से हटाएँ</string>
|
||||
<string name="remove_from_public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु सूची से हटाएँ</string>
|
||||
<string name="private_bookmarks">निजी स्मर्तव्य</string>
|
||||
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
|
||||
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
|
||||
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
|
||||
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
|
||||
<string name="remove_from_public_bookmarks">सार्वजनिक स्मर्तव्य सूची से हटाएँ</string>
|
||||
<string name="wallet_connect_service">धनकोष संयोजन सेवा</string>
|
||||
<string name="wallet_connect_service_explainer">एक नोस्ट्र रहस्य को अधिकृत करता है ज्साप भुगतान करने के लिए क्रमक से बाहर चले बिना। रहस्य को सुरक्षित रखें तथा एक निजी पुनःप्रसारक का प्रयोग करें हो सके तो।</string>
|
||||
<string name="wallet_connect_service_pubkey">धनकोष संयोजन ख्याप्यकुंचिका</string>
|
||||
<string name="wallet_connect_service_relay">धनकोष संयोजन पुनःप्रसारक</string>
|
||||
<string name="wallet_connect_service_secret">धनकोष संयोजन रहस्य</string>
|
||||
<string name="wallet_connect_service_show_secret">गुप्त कुंचिका दिखाएँ</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / षोडशांकरूप निजी कुंचिका</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">एनसेक॰ / षोडशांकरूप निजी कुंचिका</string>
|
||||
<string name="pledge_amount_in_sats">प्रतिज्ञा मात्रा साट्स में</string>
|
||||
<string name="post_poll">मतदान प्रकाशित करें</string>
|
||||
<string name="poll_heading_required">अनिवार्य प्रपत्रस्थान :</string>
|
||||
@@ -338,8 +339,8 @@
|
||||
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
|
||||
<string name="file_server">अभिलेख सेवासंगणक</string>
|
||||
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
|
||||
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया NIP: जाँच करें यदि वे सक्षम हैं</string>
|
||||
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (निप॰-९५)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं</string>
|
||||
<string name="connect_via_tor_short">टोर / ओर्बोट स्थापन</string>
|
||||
<string name="connect_via_tor">आपकी ओर्बोट स्थापना द्वारा संयोजन करें</string>
|
||||
<string name="do_you_really_want_to_disable_tor_title">क्या आपके ओर्बोट / टोर से संयोजन काट दें</string>
|
||||
@@ -347,16 +348,16 @@
|
||||
<string name="yes">हाँ</string>
|
||||
<string name="no">नहीं</string>
|
||||
<string name="follow_list_selection">अनुचरण सूची</string>
|
||||
<string name="follow_list_kind3follows">सभी अनुचरण</string>
|
||||
<string name="follow_list_kind3follows">सभी अनुचरित</string>
|
||||
<string name="follow_list_global">वैश्विक</string>
|
||||
<string name="follow_list_mute_list">मौन सूची</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">## टोर द्वारा संयोजन करें ओर्बोट के साथ
|
||||
\n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n२. ओर्बोट आरम्भ करें
|
||||
\n२. ओर्बोट चलाएँ
|
||||
\n३. ओर्बोट में, सोक्स॰ द्वार का जाँच करें। मूलविकल्पतः ९०५० का प्रयोग करता है।
|
||||
\n४. यदि आवश्यक हो तो ओर्बोट में द्वार परिवर्तित करें
|
||||
\n५. सोक्स॰ द्वार की समाकृति करें इस पटल पर
|
||||
\n६. सक्रिय करें घुण्डी दबाएँ ओर्बोट को मध्यस्थ के रूप में प्रयोग करने के लिए
|
||||
\n६. सक्रिय करें सूचकयुक्त घुण्डी दबाएँ ओर्बोट को प्रतिनिधि के रूप में प्रयोग करने के लिए
|
||||
</string>
|
||||
<string name="orbot_socks_port">ओर्बोट सोक्स॰ द्वार</string>
|
||||
<string name="invalid_port_number">द्वार अंक अमान्य</string>
|
||||
@@ -372,7 +373,7 @@
|
||||
<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 अथवा षोडशांकरूप</string>
|
||||
<string name="channel_list_user_or_group_id_demo">एनपुब॰, एनइवेण्ट॰ अथवा षोडशांकरूप</string>
|
||||
<string name="channel_list_create_channel">बनाएँ</string>
|
||||
<string name="channel_list_join_channel">जुडें</string>
|
||||
<string name="today">आज</string>
|
||||
@@ -383,7 +384,7 @@
|
||||
<string name="content_warning_see_warnings">विषयवस्तु चेतावनियाँ सर्वदा दिखाएँ</string>
|
||||
<string name="recommended_apps">पुनःप्रशंसित : </string>
|
||||
<string name="filter_spam_from_strangers">अपरिचित जन के भेजे गये कचरालेखों को छलनी द्वारा हटाएँ</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">चेतावनी दें जब पत्र सूचित किये गये हों आपके अनुचरों द्वारा</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">चेतावनी दें जब पत्र सूचित किये गये हों आपके द्वारा अनुचरित व्यक्तियों से</string>
|
||||
<string name="new_reaction_symbol">नया प्रतिक्रिया चिह्न</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">इस उपयोगकर्ता के लिए कोई प्रतिक्रिया प्रकार पूर्व चयनित नहीं। हृदयचिह्न घुण्डी पर दीर्घतः दबाएँ परिवर्तन करने के लिए</string>
|
||||
<string name="zapraiser">ज्सापोपार्जन योजना</string>
|
||||
@@ -399,7 +400,7 @@
|
||||
<string name="version">संस्करण</string>
|
||||
<string name="software">क्रमक</string>
|
||||
<string name="contact">सम्पर्क</string>
|
||||
<string name="supports">सक्षम NIPs</string>
|
||||
<string name="supports">अवलम्बन किए हुए निप॰ सूची</string>
|
||||
<string name="admission_fees">प्रवेश शुल्क</string>
|
||||
<string name="payments_url">भुगतान जालपता</string>
|
||||
<string name="limitations">परिसीमाएँ</string>
|
||||
@@ -407,6 +408,7 @@
|
||||
<string name="languages">भाषाएँ</string>
|
||||
<string name="tags">विषयसूचक</string>
|
||||
<string name="posting_policy">पत्र प्रकाशन नीति</string>
|
||||
<string name="relay_error_messages">अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से</string>
|
||||
<string name="message_length">संदेश लम्बाई</string>
|
||||
<string name="subscriptions">ग्राहकताएँ</string>
|
||||
<string name="filters">छलनियाँ</string>
|
||||
@@ -414,13 +416,13 @@
|
||||
<string name="minimum_prefix">न्यूनतम उपसर्ग</string>
|
||||
<string name="maximum_event_tags">अधिकतम घटना विषयसूचक</string>
|
||||
<string name="content_length">विषयवस्तु लम्बाई</string>
|
||||
<string name="minimum_pow">न्यूनतम PoW</string>
|
||||
<string name="minimum_pow">न्यूनतम श्रमप्रमाण</string>
|
||||
<string name="auth">प्रमाणीकरण</string>
|
||||
<string name="payment">भुगतान</string>
|
||||
<string name="cashu">काश्यु लिपिखण्ड</string>
|
||||
<string name="cashu">काशयु लिपिखण्ड</string>
|
||||
<string name="cashu_redeem">चुकाएँ</string>
|
||||
<string name="cashu_redeem_to_zap">ज्साप धनकोष को भेजें</string>
|
||||
<string name="cashu_redeem_to_cashu">काश्यु धनकोष में खोलें</string>
|
||||
<string name="cashu_redeem_to_cashu">काशयु धनकोष में खोलें</string>
|
||||
<string name="cashu_copy_token">लिपिखण्ड की अनुकृति करें</string>
|
||||
<string name="no_lightning_address_set">कोई लैटनिंग पता स्थापित नहीं</string>
|
||||
<string name="copied_token_to_clipboard">लिपिखण्ड की अनुकृति की गयी टाँकाफलक में</string>
|
||||
@@ -445,9 +447,9 @@
|
||||
<string name="add_sensitive_content_description">इसे दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है</string>
|
||||
<string name="settings">स्थापना विकल्प</string>
|
||||
<string name="connectivity_type_always">सर्वदा</string>
|
||||
<string name="connectivity_type_wifi_only">केवल वैफै</string>
|
||||
<string name="connectivity_type_wifi_only">केवल वै॰फै॰</string>
|
||||
<string name="connectivity_type_never">कभी नहीं</string>
|
||||
<string name="ui_feature_set_type_complete">सम्पन्न</string>
|
||||
<string name="ui_feature_set_type_complete">सम्पूर्ण</string>
|
||||
<string name="ui_feature_set_type_simplified">सरलीकृत</string>
|
||||
<string name="system">यन्त्रव्यवस्था</string>
|
||||
<string name="light">प्रकाशवान</string>
|
||||
@@ -458,17 +460,17 @@
|
||||
<string name="automatically_load_images_gifs">चित्र पूर्वीक्षण</string>
|
||||
<string name="automatically_play_videos">चलचित्र का चालन</string>
|
||||
<string name="automatically_show_url_preview">जालपता पूर्वीक्षण</string>
|
||||
<string name="automatically_hide_nav_bars">निमग्न पृष्ठघुमाव</string>
|
||||
<string name="automatically_hide_nav_bars">निमग्नकारी पृष्ठघुमाव</string>
|
||||
<string name="automatically_hide_nav_bars_description">मार्गदर्शनपट्टियों को छिपाएँ पृष्ठघुमाव करने पर</string>
|
||||
<string name="ui_style">प्रयोगमाध्यम शैली</string>
|
||||
<string name="ui_style_description">पत्र प्रकाशन शैली का चयन करें</string>
|
||||
<string name="load_image">चित्र प्राप्त करें</string>
|
||||
<string name="spamming_users">कचरालेख प्रेषक</string>
|
||||
<string name="muted_button">मौन किया गया। सुनने के लिए टाँकें</string>
|
||||
<string name="muted_button">मौन किया गया। अमौन करने के लिए टाँकें</string>
|
||||
<string name="mute_button">ध्वनि सक्रिय। मौन करने के लिए टाँकें</string>
|
||||
<string name="search_button">स्थानीय तथा दूरस्थ अभिलेखों को खोजें</string>
|
||||
<string name="nip05_verified">नोस्ट्र पता का सत्यापन सफल</string>
|
||||
<string name="nip05_failed">नोस्ट्र पता का सत्यातन असफल</string>
|
||||
<string name="nip05_failed">नोस्ट्र पता का सत्यापन असफल</string>
|
||||
<string name="nip05_checking">नोस्ट्र पता का जाँच चल रहा है</string>
|
||||
<string name="select_deselect_all">सब का चयन / अचयन करें</string>
|
||||
<string name="default_relays">मूलविकल्प</string>
|
||||
@@ -479,7 +481,7 @@
|
||||
<string name="geohash_explainer">आपका भूगोलिक स्थान विभेदक जोडता है पत्र में। जनता जान जाएगी कि आप वर्तमान स्थान से ५ कि॰मे॰ (३ मी॰) की दूरी के अन्दर हैं</string>
|
||||
<string name="add_sensitive_content_explainer">आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_title">नयी सुविधा</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा NIP-17 संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह NIP-17 नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं।</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं।</string>
|
||||
<string name="new_feature_nip17_activate">सक्रिय करें</string>
|
||||
<string name="messages_create_public_chat">सार्वजनिक</string>
|
||||
<string name="messages_create_public_private_chat_desription">नया निजी अथवा सार्वजनिक झुण्ड</string>
|
||||
@@ -494,12 +496,13 @@
|
||||
<string name="paste_from_clipboard">टाँकाफलक से चिपकाएँ</string>
|
||||
<string name="language_description">क्रमक के प्रयोगमाध्यम के लिए</string>
|
||||
<string name="theme_description">अन्धकारमय, प्रकाशवान अथवा यन्त्रव्यवस्थित प्रदर्शनशैली</string>
|
||||
<string name="automatically_load_images_gifs_description">स्वचालित रूप से चित्र तथा GIFs का अवरोहण करें</string>
|
||||
<string name="automatically_play_videos_description">स्वचालित रूप से चलचित्र तथा GIFs चलाता है</string>
|
||||
<string name="automatically_load_images_gifs_description">स्वचालित रूप से चित्र तथा जिफ॰ का अवरोहण करें</string>
|
||||
<string name="automatically_play_videos_description">स्वचालित रूप से चलचित्र तथा जिफ॰ चलाता है</string>
|
||||
<string name="automatically_show_url_preview_description">जालपता के पूर्वीक्षण दिखाएँ</string>
|
||||
<string name="load_image_description">चित्रों का अवरोहण कब करें</string>
|
||||
<string name="copy_to_clipboard">टाँकाफलक में अनुकृति करें</string>
|
||||
<string name="copy_npub_to_clipboard">टाँकाफलक में npub का अनुकरण करें</string>
|
||||
<string name="copy_npub_to_clipboard">टाँकाफलक में एनपुब॰ की अनुकृति करें</string>
|
||||
<string name="share_or_save">बाँटें अथवा अभिलेखन करें</string>
|
||||
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
|
||||
<string name="created_at">तब बनाया गया</string>
|
||||
@@ -548,14 +551,14 @@
|
||||
<string name="error_dialog_pay_invoice_error">चालान का भुगतान नहीं कर पाए</string>
|
||||
<string name="error_dialog_pay_withdraw_error">निकाल नहीं पाए</string>
|
||||
<string name="error_parsing_nip47_title">धनकोष संयोजन स्थापित नहीं कर पाए</string>
|
||||
<string name="error_parsing_nip47">NIP-47 संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। अपक्रम : %2$s</string>
|
||||
<string name="error_parsing_nip47_no_error">NIP-47 संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s।</string>
|
||||
<string name="cashu_failed_redemption">काश्यु नहीं चुका पाए</string>
|
||||
<string name="error_parsing_nip47">निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। अपक्रम : %2$s</string>
|
||||
<string name="error_parsing_nip47_no_error">निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s।</string>
|
||||
<string name="cashu_failed_redemption">काशयु नहीं चुका पाए</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">टकसाल ने यह अपक्रम संदेश उपलब्ध किया : %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">काश्यु लिपिखण्ड का व्यय हो चुका।</string>
|
||||
<string name="cashu_successful_redemption">काश्यु प्राप्त</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">काशयु लिपिखण्ड का व्यय हो चुका।</string>
|
||||
<string name="cashu_successful_redemption">काशयु प्राप्त</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s साट्स भेजे गये आपके धनकोष में। (शुल्क : %2$s साट्स)</string>
|
||||
<string name="cashu_no_wallet_found">कोई अनुकूल काश्यु धनकोष उपलब्ध नहीं यन्त्र में</string>
|
||||
<string name="cashu_no_wallet_found">कोई अनुकूल काशयु धनकोष उपलब्ध नहीं यन्त्र में</string>
|
||||
<string name="error_unable_to_fetch_invoice">ग्राहक के सेवा संगणक से चालान नहीं लाया जा सका</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">आपका धनकोष संयोजन प्रदाता ने यह अपक्रम लौटाया : %1$s</string>
|
||||
<string name="could_not_connect_to_tor">टोर से जुड नहीं पाए</string>
|
||||
@@ -565,17 +568,17 @@
|
||||
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct">%1$s का सुलझाव नहीं कर पाए। जाँच करें यदि आप संयोजित हैं, यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता %2$s सम्यक है</string>
|
||||
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">%1$s का सुलझाव नहीं कर पाए। जाँच करें यदि आप संयोजित हैं, यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता %2$s सम्यक है।\n\nअपवर्ग था : %3$s</string>
|
||||
<string name="could_not_fetch_invoice_from">%1$s से चालान नहीं लाया जा सका</string>
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">लैटनिंग पता से JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">%1$s से JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">लैटनिंग पता से जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">%1$s से जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration">प्रत्याह्वान जालपता उपलब्ध नहीं उपयोगकर्ता के लैटनिंग पता सेवासंगणक की समाकृति में</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">प्रत्याह्वान जालपता उपलब्ध नहीं %1$s के उत्तर से</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">लैटनिंग पता के चालान लाने के JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">%1$s के चालान लाने के JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">लैटनिंग पता से लाये गये चालान के जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">%1$s से लाए गए चालान के जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
|
||||
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">%2$s से चालान की मात्रा (%1$s साट्स) में दोष है। %3$s होना चाहिए था।</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। ग्राहक के लैटनिंग धनकोष ने यह अपक्रम की सूचना दी : %1$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">लैटनिंग चालान नहीं बना पाए। %1$s से संदेश : %2$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। परिणाम JSON में pr अम्श उपलब्ध नहीं।</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम JSON में pr अम्श उपलब्ध नहीं।</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
|
||||
<string name="read_only_user">केवल पढनेवाला उपयोगकर्ता</string>
|
||||
<string name="no_reactions_setup">कोई प्रतिक्रियाएँ स्थापित नहीं</string>
|
||||
<string name="select_push_server">संयुक्तप्रेषण क्रमक का चयन करें</string>
|
||||
@@ -585,7 +588,7 @@
|
||||
<string name="push_server_none_explainer">प्रेषित सूचनाएँ अक्षम करता है</string>
|
||||
<string name="push_server_uses_app_explainer">%1$s क्रमक का प्रयोग करता है</string>
|
||||
<string name="push_server_install_app">प्रेषित सूचना स्थापन</string>
|
||||
<string name="push_server_install_app_description"> प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [Nfty](https://ntfy.sh/)।
|
||||
<string name="push_server_install_app_description"> प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)।
|
||||
स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में।
|
||||
</string>
|
||||
<string name="payment_required_title">%1$s से संदेश</string>
|
||||
@@ -634,7 +637,7 @@
|
||||
<string name="could_not_download_from_the_server">सेवासंगणक से आरोहणकृत चित्र चलचित्र का अवरोहण नहीं कर पाए</string>
|
||||
<string name="could_not_prepare_local_file_to_upload">स्थानीय अभिलेख अनुकूल नहीं बना सके आरोहण के लिए : %1$s</string>
|
||||
<string name="edit_draft">पाण्डुलिपि का सम्पादन करें</string>
|
||||
<string name="login_with_qr_code">क्यूआर क्रमचित्र के साथ प्रवेशांकन करें</string>
|
||||
<string name="login_with_qr_code">क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें</string>
|
||||
<string name="route">मार्ग</string>
|
||||
<string name="route_home">घर</string>
|
||||
<string name="route_search">खोज</string>
|
||||
@@ -671,26 +674,38 @@
|
||||
<string name="cancel_zap_split">ज्साप विभाजन निरस्त करें</string>
|
||||
<string name="add_content_warning">विषयवस्तु चेतावनी जोडें</string>
|
||||
<string name="remove_content_warning">विषयवस्तु चेतावनी हटाएँ</string>
|
||||
<string name="show_npub_as_a_qr_code">क्यूआर क्रमचित्र के रूप में npub को दिखाएँ</string>
|
||||
<string name="show_npub_as_a_qr_code">क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ</string>
|
||||
<string name="invalid_nip19_uri">अमान्य पता</string>
|
||||
<string name="invalid_nip19_uri_description">अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s</string>
|
||||
<string name="dm_relays_title">सी॰सं॰ आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="dm_relays_through">पुनःप्रसारक : %1$s</string>
|
||||
<string name="dm_relays_regular">साधारण पुनःप्रसारक प्रयुक्त</string>
|
||||
<string name="dm_relays_not_found">आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें</string>
|
||||
<string name="dm_relays_not_found_description">यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे।</string>
|
||||
<string name="dm_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - you.nostr1.com (निजी पुनःप्रसारक - सशुल्क)</string>
|
||||
<string name="dm_relays_not_found_editing">निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को सब से सन्देश स्वीकारना चाहिए पर उनका अवरोहण करने की अनुमति केवल आपको देना चाहिए।</string>
|
||||
<string name="dm_relays_not_found_create_now">अभी स्थापना करें</string>
|
||||
<string name="search_relays_title">खोज पुनःप्रसारक</string>
|
||||
<string name="search_relays_not_found">आपके खोज पुनःप्रसारकों की स्थापना करें</string>
|
||||
<string name="search_relays_not_found_description">खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा।</string>
|
||||
<string name="search_relays_not_found_editing">विषयवस्तु खोजने के लिए अथवा उपयोगकर्ता सूचक जोडने में उपयोग करनें के लिए १ - ३ पुनःप्रसारकों को जोडें। सुनिश्चित करें कि आपके चयनित पुनःप्रसारक निप॰-५० को कार्यान्वित किये हैं।</string>
|
||||
<string name="search_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
|
||||
<string name="relay_settings">पुनःप्रसारक स्थापना विकल्प</string>
|
||||
<string name="public_home_section">सार्वजनिक मुख्य पुनःप्रसारक</string>
|
||||
<string name="public_home_section_explainer">यह पुनःप्रसारक प्रकार आपके सभी विषयवस्तु रखता है। अमेथिस्ट आपके पत्रों को प्रकाशित करने के लिए यहाँ भेजेगा तथा अन्य लोग आपके विषयवस्तु ढूँढने के लिए इनका प्रयोग करेंगे। १ - ३ पुनःप्रसारकों को जोडें। ये व्यक्तिगत अथवा सशुल्क अथवा सार्वजनिक पुनःप्रसारक हो सकते हैं।</string>
|
||||
<string name="public_notif_section">सार्वजनिक आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="public_notif_section_explainer">यह पुनःप्रसारक प्रकार आपके प्रकाशित पत्रों को भेजे गये सभी प्रतिवचन, टिप्पणियाँ, इष्टसूचक तथा ज्सापों को प्राप्त करता है। १ - ३ पुनःप्रसारकों को जोडें तथा सुनिश्चित करें कि वे किसी से भी पत्र स्वीकारते हैं।</string>
|
||||
<string name="private_inbox_section">सी॰सं॰ आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
|
||||
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
|
||||
<string name="private_outbox_section_explainer">१ - ३ पुनःप्रसारकों को जोडें उन घटनाओं को रखने के लिए जो कोई अन्य नहीं देख सकता जैसे कि पाण्डुलिपियाँ अथवा क्रमक के स्थापना विकल्प। आदर्शतः ये पुनःप्रसारक स्थानीय होने चाहिए अथवा प्रत्येक उपयोगकर्ता के विषयवस्तु अवरोहण के पूर्व परिचय प्रमाणिकरण अनिवार्य होना चाहिए।</string>
|
||||
<string name="kind_3_section">सामान्य पुनःप्रसारक</string>
|
||||
<string name="kind_3_section_description">अमेथिस्ट इन पुनःप्रसारकों का प्रयोग करता है आपके पत्रों का अवरोहण करने के लिए।</string>
|
||||
<string name="search_section">खोज पुनःप्रसारक</string>
|
||||
<string name="search_section_explainer">पुनःप्रसारकों की सूची जिनको किसी विषयवस्तु अथवा उपयोगकर्ताओं को खोजने में प्रयोग करना चाहिए। कोई विकल्प नहीं तो उपयोगकर्ता सूचक जोडना तथा खोज निष्क्रिय होंगे। सुनिश्चित करें कि वे निप॰-५० को कार्यान्वित किये हैं।</string>
|
||||
<string name="local_section">स्थानीय पुनःप्रसारक</string>
|
||||
<string name="local_section_explainer">पुनःप्रसारकों की सूची जो इस यन्त्र पर चल रहे हैं।</string>
|
||||
<string name="zap_the_devs_title">क्रमलखकों को ज्साप करें!</string>
|
||||
<string name="zap_the_devs_title">क्रमलेखकों को ज्साप करें!</string>
|
||||
<string name="zap_the_devs_description">आपका दान हमारा सहायक है परिवर्तन लाने में। प्रत्येक साट गणनीय है!</string>
|
||||
<string name="donate_now">दान करें अभी</string>
|
||||
<string name="brought_to_you_by">आप तक लाया गया इनके द्वारा :</string>
|
||||
@@ -715,11 +730,11 @@
|
||||
<string name="accessibility_download_for_offline">अवरोहण</string>
|
||||
<string name="accessibility_lyrics_on">संगीत पद्य दिखाएँ</string>
|
||||
<string name="accessibility_lyrics_off">संगीत पद्य ना दिखाएँ</string>
|
||||
<string name="accessibility_turn_on_sealed_message">आवृत संदेश निष्क्रिय। आवृत संदेश सक्रिय करने के लिए टाँकें</string>
|
||||
<string name="accessibility_turn_off_sealed_message">आवृत संदेश सक्रिय। आवृत संदेश निष्क्रिय करने के लिए टाँकें</string>
|
||||
<string name="accessibility_turn_on_sealed_message">समावृत संदेश निष्क्रिय। समावृत संदेश सक्रिय करने के लिए टाँकें</string>
|
||||
<string name="accessibility_turn_off_sealed_message">समावृत संदेश सक्रिय। समावृत संदेश निष्क्रिय करने के लिए टाँकें</string>
|
||||
<string name="accessibility_send">भेजें</string>
|
||||
<string name="accessibility_play_username">उपयोगकर्ता नाम को ध्वनि के रूप में चलाएँ</string>
|
||||
<string name="accessibility_scan_qr_code">क्यूआर क्रमचित्र परखें</string>
|
||||
<string name="accessibility_scan_qr_code">क्यूआर॰ क्रमचित्र परखें</string>
|
||||
<string name="accessibility_navigate_to_alby">तृतीय पक्ष धनकोष प्रदाता आल्बी तक जाएँ</string>
|
||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">एक टीका पाण्डुलिपि को उत्तर नहीं दे सकते</string>
|
||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">एक टीका पाण्डुलिपि पर टीका नहीं लिख सकते</string>
|
||||
@@ -727,4 +742,9 @@
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">एक टीका पाण्डुलिपि को ज्साप नहीं कर सकते</string>
|
||||
<string name="draft_note">टीका पाण्डुलिपि</string>
|
||||
<string name="load_from_text">संदेश से</string>
|
||||
<string name="dvm_looking_for_app">क्रमक ढूँढना चालू</string>
|
||||
<string name="dvm_waiting_status">कार्य का अनुरोध किया गया। उत्तर की प्रतीक्षा में</string>
|
||||
<string name="dvm_requesting_job">डीवीएम॰ से कार्य का अनुरोध किया जा रहा है</string>
|
||||
<string name="nwc_payment_request">भुगतान अनुरोध भेजा गया। आपके धनकोष से पुष्टीकरण की प्रतीक्षा में</string>
|
||||
<string name="dvm_waiting_to_confim_payment">डीवीएम॰ से भुगतान की पुष्टीकरण अथवा परिणाम भेजने की प्रतीक्षा में</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<string name="group_picture">Csoport Kép</string>
|
||||
<string name="explicit_content">Szókimondó tartalom</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="spam_description">Ebből a csomópontból érkező spam események száma</string>
|
||||
<string name="impersonation">Megszemélyesítés</string>
|
||||
<string name="illegal_behavior">Illegális viselkedés</string>
|
||||
<string name="other">Egyéb</string>
|
||||
@@ -93,6 +94,7 @@
|
||||
<string name="posts">Hozzászólások</string>
|
||||
<string name="bytes">Bájtok</string>
|
||||
<string name="errors">Hibák</string>
|
||||
<string name="errors_description">Ebben a munkamenetben lévő kapcsolati hibák száma</string>
|
||||
<string name="home_feed">Hírfolyamod</string>
|
||||
<string name="private_message_feed">Privát Üzenetek listája</string>
|
||||
<string name="public_chat_feed">Publikus Chat listája</string>
|
||||
@@ -389,6 +391,8 @@
|
||||
<string name="sats_to_complete">A ZapGyűjtés %1$s-nál. %2$s sats kell a célig</string>
|
||||
<string name="read_from_relay">Olvassás a csomópontból</string>
|
||||
<string name="write_to_relay">Írás a csomópontra</string>
|
||||
<string name="write_to_relay_description">Ehhez a csomóponthoz küldött bájtok mennyisége, beleértve a szűrőket és az eseményeket</string>
|
||||
<string name="read_from_relay_description">Ebből a csomópontból fogadott bájtok mennyisége, beleértve a szűrőket és az eseményeket</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Hiba történt a közvetítő információinak lekérése közben a %1$s -ról</string>
|
||||
<string name="owner">Tulajdonos</string>
|
||||
<string name="version">Verzió</string>
|
||||
@@ -402,6 +406,7 @@
|
||||
<string name="languages">Nyelvek</string>
|
||||
<string name="tags">Címkék</string>
|
||||
<string name="posting_policy">Szabályzat</string>
|
||||
<string name="relay_error_messages">Hibák és megjegyzések ettől a csomóponttól</string>
|
||||
<string name="message_length">Üzenet hossza</string>
|
||||
<string name="subscriptions">Feliratkozások</string>
|
||||
<string name="filters">Filterek</string>
|
||||
@@ -669,9 +674,34 @@
|
||||
<string name="show_npub_as_a_qr_code">Az npub megjelenítése QR-kódként</string>
|
||||
<string name="invalid_nip19_uri">Érvénytelen cím</string>
|
||||
<string name="invalid_nip19_uri_description">Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s</string>
|
||||
<string name="dm_relays_title">PÜ Bejövő Csomópontok</string>
|
||||
<string name="dm_relays_through">Csomópontok: %1$s</string>
|
||||
<string name="dm_relays_regular">Általános csomópontok használata</string>
|
||||
<string name="dm_relays_not_found">Állítsd be a Privát postafiókod közvetítőit</string>
|
||||
<string name="dm_relays_not_found_description">Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz.</string>
|
||||
<string name="dm_relays_not_found_examples">Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string>
|
||||
<string name="dm_relays_not_found_editing">Adj hozzá 1–3 csomópontot, hogy privát postafiókodként szolgáljon. A PÜ Bejővő csomópontoknak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Te általad teszik lehetővé.</string>
|
||||
<string name="dm_relays_not_found_create_now">Állítsd be most</string>
|
||||
<string name="search_relays_title">Kereső Csomópontok</string>
|
||||
<string name="search_relays_not_found">Állítsd be a Kereséső csomópontjaidat</string>
|
||||
<string name="search_relays_not_found_description">A kifejezetten kereséshez és felhasználói címkézéshez tervezett csomópontok listájának létrehozása javítja a találati eredményeket.</string>
|
||||
<string name="search_relays_not_found_editing">Adj hozzá 1–3 csomópontot tartalom kereséshez vagy a felhasználók címkézéséhez. Győződj meg arról, hogy a kiválasztott csomópontok alkalmazzák a NIP-50-et</string>
|
||||
<string name="search_relays_not_found_examples">Jó lehetőségek a következők:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
|
||||
<string name="relay_settings">Csomópont Beállítások</string>
|
||||
<string name="public_home_section">Publikus Otthoni Csomópontok</string>
|
||||
<string name="public_home_section_explainer">Ez a csomópont típus az összes tartalmat tárolja. Az Amethyst ide küldi a bejegyzéseidet, mások pedig ezeket a csomópontokat fogják használni, hogy a tartalmadat megtalálják. Adj hozzá 1-3 csomópontot. Lehetnek saját, fizetős vagy nyilvános csomópontok.</string>
|
||||
<string name="public_notif_section">Publikus Bejövő Csomópontok</string>
|
||||
<string name="public_notif_section_explainer">Ez a csomópont típus fogadja a bejegyzéseidre adott összes választ, megjegyzést, tetszésnyilvánítást és visszajelzést. Adj hozzá 1–3 csomópontot, és győződj meg arról, hogy ezek bárkitől fogadnak bejegyzéseket.</string>
|
||||
<string name="private_inbox_section">PÜ Bejövő Csomópontok</string>
|
||||
<string name="private_inbox_section_explainer">Adj hozzá 1–3 csomópontot, hogy privát postafiókodként szolgáljon. Mások ezeket a csomópontokat használják, hogy neked PÜ-eket küldjenek. A PÜ Bejövő csomópontoknak bárkitől kell fogadniuk bármely üzenetet, de azok letöltését csak a Te részedre teszik lehetővé. Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string>
|
||||
<string name="private_outbox_section">Privát Csomópontok</string>
|
||||
<string name="private_outbox_section_explainer">Adj hozzá 1–3 csomópontot, hogy olyan eseményeket tároljon, amelyeket senki más nem láthat, például a piszkozataidat és/vagy az alkalmazásbeállításaidat. Ideális esetben ezek a csomópontok vagy helyiek, vagy az egyes felhasználók tartalmának letöltése előtt hitelesítést igényelnek.</string>
|
||||
<string name="kind_3_section">Általános Csomópontok</string>
|
||||
<string name="kind_3_section_description">Az Amethyst ezeket a csomópontokat a bejegyzések letöltésére használja.</string>
|
||||
<string name="search_section">Kereső Csomópontok</string>
|
||||
<string name="search_section_explainer">A tartalom vagy a felhasználók kereséséhez használt csomópontok listája. A címkézés és a keresés nem működik, ha itt nincsenek megadott lehetőségek. Győződj meg arról, hogy alkalmazzák a NIP-50-et.</string>
|
||||
<string name="local_section">Helyi Csomópontok</string>
|
||||
<string name="local_section_explainer">Ezen eszközön futó csomópontok listája.</string>
|
||||
<string name="zap_the_devs_title">Zap a fejlesztőknek!</string>
|
||||
<string name="zap_the_devs_description">Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít!</string>
|
||||
<string name="donate_now">Adományozz Most</string>
|
||||
@@ -709,4 +739,9 @@
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">A bejegyzésvázlatra nem lehet zap-pelni</string>
|
||||
<string name="draft_note">Bejegyzéspiszkozat</string>
|
||||
<string name="load_from_text">Tőle üzenet</string>
|
||||
<string name="dvm_looking_for_app">Alkalmazást keresek</string>
|
||||
<string name="dvm_waiting_status">Feladat kérve, válaszra várunk</string>
|
||||
<string name="dvm_requesting_job">Feladatot kér a DVM-től</string>
|
||||
<string name="nwc_payment_request">Fizetési kérelem elküldve, megerősítésére vár a tárcádtól</string>
|
||||
<string name="dvm_waiting_to_confim_payment">Várakozás a DVM-re a fizetés megerősítésére vagy az eredmények elküldésére</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
<style name="Theme.Amethyst" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/purple_700</item>
|
||||
<item name="android:windowBackground">@color/black</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -8,15 +8,18 @@
|
||||
<string name="show_anyway">Pokaż mimo wszystko</string>
|
||||
<string name="post_was_hidden">Ten post został ukryty, ponieważ dotyczy ukrytych użytkowników lub słów</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post został wyciszony lub zgłoszony przez</string>
|
||||
<string name="post_not_found">Wydarzenie jest wczytywane lub nie można go znaleźć na liście transmiterów</string>
|
||||
<string name="channel_image">Zdjęcie kanału</string>
|
||||
<string name="referenced_event_not_found">Przywołane zdarzenie nie zostało znalezione</string>
|
||||
<string name="could_not_decrypt_the_message">Nie można odszyfrować wiadomości</string>
|
||||
<string name="group_picture">Zdjęcie grupy</string>
|
||||
<string name="explicit_content">Niedozwolona zawartość</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="spam_description">Liczba spamu z tego transmitera</string>
|
||||
<string name="impersonation">Podszywanie się</string>
|
||||
<string name="illegal_behavior">Nielegalne zachowanie</string>
|
||||
<string name="relay_icon">Ikona retransmitera</string>
|
||||
<string name="unknown">Nieznany</string>
|
||||
<string name="relay_icon">Ikona transmitera</string>
|
||||
<string name="unknown_author">Autor nieznany</string>
|
||||
<string name="copy_text">Skopiuj tekst</string>
|
||||
<string name="copy_user_pubkey">Kopiuj identyfikator autora</string>
|
||||
@@ -72,22 +75,29 @@
|
||||
<string name="save">Zapisz</string>
|
||||
<string name="create">Utwórz</string>
|
||||
<string name="cancel">Anuluj</string>
|
||||
<string name="relay_address">Adres retransmitera</string>
|
||||
<string name="relay_address">Adres transmitera</string>
|
||||
<string name="posts">Wpisy</string>
|
||||
<string name="errors">Błędy</string>
|
||||
<string name="add_a_relay">Dodaj Retransmiter</string>
|
||||
<string name="global_feed">Wszystkie</string>
|
||||
<string name="add_a_relay">Dodaj Transmiter</string>
|
||||
<string name="username">Nazwa użytkownika</string>
|
||||
<string name="about_me">O mnie</string>
|
||||
<string name="avatar_url">URL awatara</string>
|
||||
<string name="banner_url">URL banera</string>
|
||||
<string name="website_url">Adres URL strony</string>
|
||||
<string name="save_to_gallery">Zapisz w galerii</string>
|
||||
<string name="image_saved_to_the_gallery">Zdjęcie zapisane w galerii</string>
|
||||
<string name="failed_to_save_the_image">Nie udało się zapisać zdjęcia</string>
|
||||
<string name="upload_image">Dodaj zdjęcie</string>
|
||||
<string name="uploading">Wgrywanie…</string>
|
||||
<string name="reply_here">"odpowiedz tutaj.. "</string>
|
||||
<string name="join">Dołącz</string>
|
||||
<string name="blocked_users">Zablokowani użytkownicy</string>
|
||||
<string name="notes">Notatki</string>
|
||||
<string name="replies">Odpowiedzi</string>
|
||||
<string name="reports">"Zgłoszenia"</string>
|
||||
<string name="more_options">Więcej opcji</string>
|
||||
<string name="relays">" Retransmitery"</string>
|
||||
<string name="relays">" Transmitery"</string>
|
||||
<string name="website">Strona www</string>
|
||||
<string name="send_a_direct_message">Wyślij bezpośrednią wiadomość</string>
|
||||
<string name="edits_the_user_s_metadata">Edytowanie metadanych użytkowników</string>
|
||||
@@ -119,6 +129,7 @@
|
||||
<string name="create_a_new_account">Utwórz nowe konto</string>
|
||||
<string name="generate_a_new_key">Wygeneruj nowy klucz</string>
|
||||
<string name="try_again">Spróbuj ponownie</string>
|
||||
<string name="feed_is_empty">Brak zawartości.</string>
|
||||
<string name="refresh">Odśwież</string>
|
||||
<string name="and_picture">i zdjęcie</string>
|
||||
<string name="changed_chat_name_to">zmieniono nazwę czatu na</string>
|
||||
@@ -157,7 +168,7 @@
|
||||
<string name="quick_action_unfollow">Przestań obserwować</string>
|
||||
<string name="quick_action_follow">Śledź</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Poproś o usunięcie</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst poprosi o usunięcie Twojej notatki z aktualnie podłączonych retransmitorów. Nie ma gwarancji, że Twoja notatka zostanie trwale usunięta z tych retransmitorów lub z innych retransmitorów, gdzie może być przechowywana.</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst poprosi o usunięcie Twojej notatki z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twoja notatka zostanie trwale usunięta z tych lub z innych transmiterów, gdzie może być przechowywana.</string>
|
||||
<string name="quick_action_block_dialog_btn">Zablokuj</string>
|
||||
<string name="quick_action_delete_dialog_btn">Usuń</string>
|
||||
<string name="quick_action_block">Zablokuj</string>
|
||||
@@ -186,6 +197,7 @@
|
||||
<string name="add_to_public_bookmarks">Dodaj do publicznych zakładek</string>
|
||||
<string name="remove_from_private_bookmarks">Usuń z prywatnych zakładek</string>
|
||||
<string name="remove_from_public_bookmarks">Usuń z publicznych zakładek</string>
|
||||
<string name="wallet_connect_service_explainer">Autoryzuje Nostr Secret do płacenia zapów bez opuszczania aplikacji. Strzeż pilnie i użyj prywatnego transmitera, jeśli to możliwe</string>
|
||||
<string name="wallet_connect_service_show_secret">Pokaż tajny klucz</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time_days">dni</string>
|
||||
@@ -193,17 +205,38 @@
|
||||
<string name="custom_zaps_add_a_message_private">Dodaj prywatną wiadomość</string>
|
||||
<string name="custom_zaps_add_a_message_example">Dziękujemy za całą twoją pracę!</string>
|
||||
<string name="lightning_create_and_add_invoice">Utwórz i Dodaj</string>
|
||||
<string name="content_description_add_image">Dodaj zdjęcie</string>
|
||||
<string name="content_description_add_video">Dodaj wideo</string>
|
||||
<string name="content_description_add_document">Dodaj dokument</string>
|
||||
<string name="add_content">Dodaj do wiadomości</string>
|
||||
<string name="content_description">Opis zawartości</string>
|
||||
<string name="zap_type_public_explainer">Każdy może zobaczyć transakcję i wiadomość</string>
|
||||
<string name="zap_type_private_explainer">Nadawca i odbiorca mogą zobaczyć się nawzajem i przeczytać wiadomość</string>
|
||||
<string name="upload_server_relays_nip95">Twoje retransmitery (NIP-95)</string>
|
||||
<string name="file_server">Serwer Plików</string>
|
||||
<string name="upload_server_relays_nip95">Twoje transmitery (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany</string>
|
||||
<string name="connect_via_tor_short">Tor/Orbot - Ustawienia</string>
|
||||
<string name="connect_via_tor">Połącz przez swoją konfigurację Orbota</string>
|
||||
<string name="do_you_really_want_to_disable_tor_title">Odłączyć od Orbota i Tora?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Twoje dane zostaną natychmiast przekazane w ramach zwykłej sieci</string>
|
||||
<string name="yes">Tak</string>
|
||||
<string name="no">Nie</string>
|
||||
<string name="follow_list_selection">Lista obserwowanych</string>
|
||||
<string name="follow_list_kind3follows">Obserwowane</string>
|
||||
<string name="follow_list_global">Wszystkie</string>
|
||||
<string name="follow_list_mute_list">Zablokowane</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown"> ## Połącz przez Tor z Orbotem
|
||||
\n\n1. Zainstaluj [Orbota](https://play.google. om/store/apps/details?id= org.torproject.android)
|
||||
\n2. Uruchom Orbota
|
||||
\n3. W Orbocie sprawdź port Socks. Domyślna używany jest 9050
|
||||
\n4. Jeśli to konieczne, zmień port w Orbocie
|
||||
\n5. Skonfiguruj port Socks w tym ekranie
|
||||
\n6. Naciśnij przycisk Aktywuj, aby użyć Orbota jako proxy
|
||||
</string>
|
||||
<string name="orbot_socks_port">Port Socks Orbota</string>
|
||||
<string name="invalid_port_number">Nieprawidłowy numer portu</string>
|
||||
<string name="use_orbot">Użyj Orbota</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Odłącz Tor/Orbota</string>
|
||||
<string name="app_notification_dms_channel_name">Prywatne Wiadomości</string>
|
||||
<string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string>
|
||||
<string name="app_notification_zaps_channel_name">Otrzymano Zapy</string>
|
||||
@@ -219,11 +252,11 @@
|
||||
<string name="content_warning_hide_all_sensitive_content">Zawsze ukrywaj wrażliwe treści</string>
|
||||
<string name="content_warning_show_all_sensitive_content">Zawsze pokazuj wrażliwą zawartość</string>
|
||||
<string name="content_warning_see_warnings">Zawsze pokazuj ostrzeżenia dotyczące zawartości</string>
|
||||
<string name="filter_spam_from_strangers">Filtrowanie spamu od nieznajomych</string>
|
||||
<string name="filter_spam_from_strangers">Filtruj spam z nieznajomych</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz</string>
|
||||
<string name="read_from_relay">Odczytaj z Retransmitera</string>
|
||||
<string name="write_to_relay">Zapisz do Retransmitera</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Wystąpił błąd podczas próby uzyskania informacji o retransmiterze z %1$s</string>
|
||||
<string name="read_from_relay">Odczytaj z Transmitera</string>
|
||||
<string name="write_to_relay">Zapisz do Transmitera</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Wystąpił błąd podczas próby uzyskania informacji o transmiterze z %1$s</string>
|
||||
<string name="owner">Właściciel</string>
|
||||
<string name="version">Wersja</string>
|
||||
<string name="software">Oprogramowanie</string>
|
||||
@@ -232,11 +265,13 @@
|
||||
<string name="limitations">Ograniczenia</string>
|
||||
<string name="countries">Kraje</string>
|
||||
<string name="languages">Języki</string>
|
||||
<string name="tags">Tagi</string>
|
||||
<string name="posting_policy">Polityka publikowania</string>
|
||||
<string name="message_length">Długość wiadomości</string>
|
||||
<string name="subscriptions">Subskrybcje</string>
|
||||
<string name="filters">Filtry</string>
|
||||
<string name="relay_setup">Retransmitery</string>
|
||||
<string name="cashu">Cashu Token</string>
|
||||
<string name="relay_setup">Transmitery</string>
|
||||
<string name="discover_community">Społeczność</string>
|
||||
<string name="discover_chat">Czaty</string>
|
||||
<string name="community_approved_posts">Zatwierdzone posty</string>
|
||||
@@ -248,11 +283,21 @@
|
||||
<string name="connectivity_type_always">Zawsze</string>
|
||||
<string name="connectivity_type_wifi_only">Tylko WiFi</string>
|
||||
<string name="connectivity_type_never">Nigdy</string>
|
||||
<string name="ui_feature_set_type_complete">Kompletny</string>
|
||||
<string name="ui_feature_set_type_simplified">Uproszczony</string>
|
||||
<string name="system">Automatyczny</string>
|
||||
<string name="light">Jasny</string>
|
||||
<string name="dark">Ciemny</string>
|
||||
<string name="application_preferences">Ustawienia Aplikacji</string>
|
||||
<string name="language">Język</string>
|
||||
<string name="theme">Motyw</string>
|
||||
<string name="automatically_load_images_gifs">Podgląd obrazu</string>
|
||||
<string name="automatically_play_videos">Odtwarzanie wideo</string>
|
||||
<string name="automatically_play_videos">Odtwarzanie filmów</string>
|
||||
<string name="automatically_show_url_preview">Podgląd URL</string>
|
||||
<string name="automatically_hide_nav_bars">Aktywne Przewijanie</string>
|
||||
<string name="automatically_hide_nav_bars_description">Ukrywa pasek przewijania</string>
|
||||
<string name="ui_style">Tryb interfejsu</string>
|
||||
<string name="ui_style_description">Wybierz styl wpisu</string>
|
||||
<string name="load_image">Załaduj obraz</string>
|
||||
<string name="spamming_users">Spamerzy</string>
|
||||
<string name="muted_button">Wyciszone. Kliknij, aby wyłączyć wyciszenie</string>
|
||||
@@ -261,24 +306,37 @@
|
||||
<string name="nip05_failed">Nieudana weryfikacja adresu Nostr</string>
|
||||
<string name="nip05_checking">Sprawdzanie adresu Nostr</string>
|
||||
<string name="select_deselect_all">Zaznacz/Odznacz wszystko</string>
|
||||
<string name="select_a_relay_to_continue">Wybierz retransmiter, aby kontynuować</string>
|
||||
<string name="default_relays">Domyślne</string>
|
||||
<string name="select_a_relay_to_continue">Wybierz Transmiter, aby kontynuować</string>
|
||||
<string name="zap_forward_title">Przekaż zapy do:</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">Aktywacja tego trybu wymaga wysłania wiadomości NIP-17 przez Ametyst. (Prezent, Zapieczętowane wiadomości bezpośrednie i grupowe). NIP-17 jest nowy, a większość klientów jeszcze go nie zaimplementowała. Upewnij się, że odbiorca używa kompatybilnego klienta.</string>
|
||||
<string name="messages_new_message_to">Do</string>
|
||||
<string name="messages_new_message_subject">Temat</string>
|
||||
<string name="messages_new_message_subject_caption">Temat dyskusji</string>
|
||||
<string name="messages_new_message_to_caption">"\@Użytkownik1, @Użytkownik2, @Użytkownik3"</string>
|
||||
<string name="messages_group_descriptor">Członkowie tej grupy</string>
|
||||
<string name="language_description">Dla interfejsu aplikacji</string>
|
||||
<string name="theme_description">Motyw ciemny, jasny lub automatyczny</string>
|
||||
<string name="automatically_play_videos_description">Automatycznie odtwarzaj filmy i pliki GIF</string>
|
||||
<string name="automatically_show_url_preview_description">Pokaż podgląd linków</string>
|
||||
<string name="copy_to_clipboard">Kopiuj do schowka</string>
|
||||
<string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string>
|
||||
<string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID notatki do schowka</string>
|
||||
<string name="status_update">Zaktualizuj status</string>
|
||||
<string name="lightning_wallets_not_found">Błąd podczas analizowania komunikatu o błędzie</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Głosy są oceniane na podstawie ilości zapperów. Możesz ustawić minimalną kwotę, aby uniknąć spamerów i maksymalną kwotę, aby uniknąć przejęcia ankiety przez dużych zapperów. Użyj tej samej kwoty w obu polach, aby upewnić się, że każdy głos ma taką samą wartość. Pozostaw to pole puste, aby zaakceptować dowolną kwotę.</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="active_for_global">Wszystkie</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="hidden_words">Ukryte słowa</string>
|
||||
<string name="automatically_show_profile_picture">Zdjęcie profilowe</string>
|
||||
<string name="automatically_show_profile_picture_description">Pokaż zdjęcia profilowe</string>
|
||||
<string name="select_an_option">Wybierz opcję</string>
|
||||
<string name="unable_to_download_relay_document">Nie można pobrać dokumentu retransmitera</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string>
|
||||
<string name="could_not_connect_to_tor">Połączenie z serwerem Tor nieudane</string>
|
||||
<string name="unable_to_download_relay_document">Nie można pobrać dokumentu transmitera</string>
|
||||
<string name="read_only_user">Użytkownik z prawem \"tylko do odczytu\"</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_price">Cena (w Satach)</string>
|
||||
<string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string>
|
||||
@@ -291,12 +349,47 @@
|
||||
<string name="classifieds_category_fitness">Fitness</string>
|
||||
<string name="classifieds_category_art">Sztuka</string>
|
||||
<string name="classifieds_category_office">Biuro</string>
|
||||
<string name="relay_info">Retransmiter %1$s</string>
|
||||
<string name="expand_relay_list">Rozwiń listę retransmiterów</string>
|
||||
<string name="relay_list_selector">Wybór listy retransmiterów</string>
|
||||
<string name="classifieds_category_food">Żywność</string>
|
||||
<string name="classifieds_category_misc">Różne</string>
|
||||
<string name="classifieds_category_other">Inne</string>
|
||||
<string name="route_security_filters">Filtry bezpieczeństwa</string>
|
||||
<string name="new_post">Nowy post</string>
|
||||
<string name="relay_info">Transmiter %1$s</string>
|
||||
<string name="expand_relay_list">Rozwiń listę transmiterów</string>
|
||||
<string name="relay_list_selector">Wybór listy transmiterów</string>
|
||||
<string name="add_content_warning">Dodaj ostrzeżenie o treści</string>
|
||||
<string name="remove_content_warning">Usuń ostrzeżenie o treści</string>
|
||||
<string name="show_npub_as_a_qr_code">Pokaż npub jako QR kod</string>
|
||||
<string name="invalid_nip19_uri">Nieprawidłowy adres</string>
|
||||
<string name="dm_relays_title">Transmitery Skrzynki Odbiorczej</string>
|
||||
<string name="dm_relays_not_found">Skonfiguruj prywatne transmitery odbiorcze</string>
|
||||
<string name="dm_relays_not_found_examples">Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
|
||||
<string name="dm_relays_not_found_editing">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Transmitery DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie.</string>
|
||||
<string name="dm_relays_not_found_create_now">Skonfiguruj teraz</string>
|
||||
<string name="search_relays_title">Transmitery wyszukujące</string>
|
||||
<string name="search_relays_not_found">Skonfiguruj swoje Transmitery wyszukujące</string>
|
||||
<string name="search_relays_not_found_editing">Wstaw od 1 do 3 transmiterów, które będą używane podczas wyszukiwania treści lub tagowania użytkowników. Upewnij się, że zaimplementowano NIP-50</string>
|
||||
<string name="relay_settings">Ustawienia Transmiterów</string>
|
||||
<string name="public_home_section">Publiczne transmitery domowe</string>
|
||||
<string name="public_home_section_explainer">Ten typ transmitera przechowuje całą zawartość. Amethyst będzie wysyłać Twoje posty tutaj, a inni będą korzystać z tych transmiterów, aby znaleźć Twoje treści. Wstaw od 1 do 3 transmiterów. Mogą to być transmitery prywatne, płatne lub publiczne.</string>
|
||||
<string name="public_notif_section">Publiczne transmitery odbiorcze</string>
|
||||
<string name="public_notif_section_explainer">Ten typ transmitera odbiera wszystkie odpowiedzi, komentarze, polubienia i zapy do Twoich postów. Mogą to być płatne lub bezpłatne transmitery. Limity ustawione przez operatora transmitera mogą ograniczać otrzymywane powiadomienia, zarówno te dobre, jak i złe. Na przykład, jeśli jesteś atakowany przez spam w komentarzach, płatne transmitery mogą odfiltrować spam. Wstaw od 1 do 3 transmiterów.</string>
|
||||
<string name="private_inbox_section">Odbiorcze transmitery DM</string>
|
||||
<string name="private_inbox_section_explainer">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
|
||||
<string name="private_outbox_section">Transmitery Prywatne</string>
|
||||
<string name="private_outbox_section_explainer">Wstaw od 1 do 3 transmiterów przechowujących dane zdarzeń, których nikt inny nie widzi, takich jak Wersje robocze i/lub ustawienia aplikacji. Idealnie byłoby, gdyby te transmitery były lokalne lub wymagały uwierzytelnienia przed pobraniem treści każdego użytkownika.</string>
|
||||
<string name="kind_3_section">Transmitery ogólne</string>
|
||||
<string name="kind_3_section_description">Amethyst używa tych przekaźników, aby pobrać dla Ciebie posty.</string>
|
||||
<string name="search_section">Transmitery wyszukujące</string>
|
||||
<string name="search_section_explainer">Lista transmiterów używanych podczas wyszukiwania treści lub użytkowników. Tagowanie i wyszukiwanie nie będą działać, jeśli nie będą dostępne żadne opcje. Upewnij się, że zaimplementowano NIP-50.</string>
|
||||
<string name="local_section">Lokalne Transmitery</string>
|
||||
<string name="local_section_explainer">Lista transmiterów działających na tym urządzeniu.</string>
|
||||
<string name="donate_now">Przekaż darowiznę</string>
|
||||
<string name="brought_to_you_by">został Ci dostarczony przez:</string>
|
||||
<string name="this_version_brought_to_you_by">Ta wersja została Ci dostarczona przez:</string>
|
||||
<string name="version_name">Wersja %1$s</string>
|
||||
<string name="thank_you">Dziękuję!</string>
|
||||
<string name="max_limit">Maksymalny Limit</string>
|
||||
<string name="accessibility_send">Wyślij</string>
|
||||
<string name="accessibility_scan_qr_code">Zeskanuj QR kod</string>
|
||||
</resources>
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<string name="website_url">URL do Site</string>
|
||||
<string name="ln_address">Endereço LN</string>
|
||||
<string name="ln_url_outdated">LN URL (desatualizado)</string>
|
||||
<string name="save_to_gallery">Salvar na Galeria</string>
|
||||
<string name="image_saved_to_the_gallery">Imagem salva para a galeria</string>
|
||||
<string name="failed_to_save_the_image">Falha ao salvar a imagem</string>
|
||||
<string name="upload_image">Enviar Imagem</string>
|
||||
@@ -405,6 +406,7 @@
|
||||
<string name="languages">Línguas</string>
|
||||
<string name="tags">Cerquilhas</string>
|
||||
<string name="posting_policy">Política de postagem</string>
|
||||
<string name="relay_error_messages">Erros e Avisos deste Relé</string>
|
||||
<string name="message_length">Tamanho da mensagem</string>
|
||||
<string name="subscriptions">Assinaturas</string>
|
||||
<string name="filters">Filtros</string>
|
||||
@@ -498,6 +500,7 @@
|
||||
<string name="load_image_description">Quando carregar imagens</string>
|
||||
<string name="copy_to_clipboard">Copiar para a Área de Transferência</string>
|
||||
<string name="copy_npub_to_clipboard">Copiar npub para a área de transferência</string>
|
||||
<string name="share_or_save">Compartilhar ou Salvar</string>
|
||||
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
|
||||
<string name="created_at">Criado em</string>
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<string name="website_url">Websida URL</string>
|
||||
<string name="ln_address">LN Adress</string>
|
||||
<string name="ln_url_outdated">LN URL (Utdaterad)</string>
|
||||
<string name="save_to_gallery">Spara i Galleri</string>
|
||||
<string name="image_saved_to_the_gallery">Bild sparad till galleriet</string>
|
||||
<string name="failed_to_save_the_image">Misslyckades att spara bilden</string>
|
||||
<string name="upload_image">Ladda upp bilden</string>
|
||||
@@ -404,6 +405,7 @@
|
||||
<string name="languages">Språk</string>
|
||||
<string name="tags">Taggar</string>
|
||||
<string name="posting_policy">Publiceringspolicy</string>
|
||||
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
|
||||
<string name="message_length">Meddelandelängd</string>
|
||||
<string name="subscriptions">Prenumerationer</string>
|
||||
<string name="filters">Filter</string>
|
||||
@@ -497,6 +499,7 @@
|
||||
<string name="load_image_description">När bilder ska laddas</string>
|
||||
<string name="copy_to_clipboard">Kopiera till Urklipp</string>
|
||||
<string name="copy_npub_to_clipboard">Kopiera npub till urklipp</string>
|
||||
<string name="share_or_save">Dela eller Spara</string>
|
||||
<string name="copy_url_to_clipboard">Kopiera URL till urklipp</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
|
||||
<string name="created_at">Skapad den</string>
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
<string name="group_picture">รูปภาพกลุ่ม</string>
|
||||
<string name="explicit_content">เนื้อหาที่มีความรุนแรง</string>
|
||||
<string name="spam">สแปม</string>
|
||||
<string name="spam_description">สแปมอีเว้นถูกรับมาจาก relay นี้</string>
|
||||
<string name="impersonation">เลียนแบบ</string>
|
||||
<string name="illegal_behavior">พฤติกรรมที่ผิดกฎหมาย</string>
|
||||
<string name="other">อื่นๆ</string>
|
||||
<string name="unknown">ไม่ทราบ</string>
|
||||
<string name="relay_icon">รีเลย์ไอคอน</string>
|
||||
<string name="unknown_author">ไม่ทราบผู้เขียน</string>
|
||||
<string name="copy_text">คัดลอกข่้อความ</string>
|
||||
<string name="copy_text">คัดลอกข้อความ</string>
|
||||
<string name="copy_user_pubkey">คัดลอก ID ผู้เขียน</string>
|
||||
<string name="copy_note_id">คัดลอก ID โน้ต</string>
|
||||
<string name="broadcast">เพยแพร่</string>
|
||||
@@ -93,6 +94,7 @@
|
||||
<string name="posts">โพสต์</string>
|
||||
<string name="bytes">ไบต์</string>
|
||||
<string name="errors">ข้อผิดพลาด</string>
|
||||
<string name="errors_description">เกิดข้อผิดพลาดในการเชื่อมต่อ</string>
|
||||
<string name="home_feed">ฟีดเริ่มต้น</string>
|
||||
<string name="private_message_feed">ฟีดข้อความส่วนตัว</string>
|
||||
<string name="public_chat_feed">ฟีดข้อความ</string>
|
||||
@@ -111,6 +113,7 @@
|
||||
<string name="website_url">ที่อยู่ URL เว็บไซต์</string>
|
||||
<string name="ln_address">Lightning Address</string>
|
||||
<string name="ln_url_outdated">LN URL (หมดอายุ)</string>
|
||||
<string name="save_to_gallery">บันทึกรูปภาพ</string>
|
||||
<string name="image_saved_to_the_gallery">บันทึกรูปภาพลงแกลเลอรี</string>
|
||||
<string name="failed_to_save_the_image">เกิดข้อผิดพลาดในการบันทึกรูปภาพ</string>
|
||||
<string name="upload_image">อัพโหลดรูปภาพ</string>
|
||||
@@ -389,6 +392,8 @@
|
||||
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats เพื่อไปถึงเป้าหมาย</string>
|
||||
<string name="read_from_relay">อ่านจาก Relay</string>
|
||||
<string name="write_to_relay">เขียนบน Relay</string>
|
||||
<string name="write_to_relay_description">จำนวนข้อมูลในหน่วยไบต์ที่ถูกส่งไปยังรีเลย์นี้ รวมถึงฟิลเตอร์และอีเว้นต่างๆ</string>
|
||||
<string name="read_from_relay_description">จำนวนข้อมูลในหน่วยไบต์ที่ถูกรับมาจากรีเลย์นี้ รวมถึงฟิลเตอร์และอีเว้นต่างๆ</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">เกิดข้อผิดพลาดในการรับข้อมูลจาก %1$s</string>
|
||||
<string name="owner">เจ้าของ</string>
|
||||
<string name="version">เวอร์ชัน</string>
|
||||
@@ -402,6 +407,7 @@
|
||||
<string name="languages">ภาษา</string>
|
||||
<string name="tags">แท็ก</string>
|
||||
<string name="posting_policy">นโยบายการโพสต์</string>
|
||||
<string name="relay_error_messages">ประกาศจากทางรีเลย์</string>
|
||||
<string name="message_length">ความยาวข้อความ</string>
|
||||
<string name="subscriptions">การสมัครสมาชิก</string>
|
||||
<string name="filters">ฟิลเตอร์</string>
|
||||
@@ -428,6 +434,7 @@
|
||||
<string name="are_you_sure_you_want_to_log_out">การออกจากระบบจะลบข้อมูลทั้งหมดของคุณ ตรวจสอบให้แน่ใจว่าได้สํารองข้อมูล private key ไว้เพื่อหลีกเลี่ยงการสูญเสียบัญชีของคุณ คุณต้องการดําเนินการต่อหรือไม่?</string>
|
||||
<string name="followed_tags">ติดตามแท็ก</string>
|
||||
<string name="relay_setup">รีเลย์</string>
|
||||
<string name="discover_content">ค้นหาโน๊ตใหม่ ๆ</string>
|
||||
<string name="discover_marketplace">ตลาด</string>
|
||||
<string name="discover_live">ถ่ายทอดสด</string>
|
||||
<string name="discover_community">ชุมชน</string>
|
||||
@@ -494,6 +501,7 @@
|
||||
<string name="load_image_description">ควรโหลดรูปภาพเมื่อใด</string>
|
||||
<string name="copy_to_clipboard">คัดลอก</string>
|
||||
<string name="copy_npub_to_clipboard">คัดลอก npub ไปยังคลิปบอร์ด</string>
|
||||
<string name="share_or_save">แชร์ หรือเก็บไว้</string>
|
||||
<string name="copy_url_to_clipboard">คัดลอก URL ลงคลิปบอร์ด</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">คัดลอก โน้ต ID ลงคลิปบอร์ดd</string>
|
||||
<string name="created_at">สร้างโดย</string>
|
||||
@@ -666,6 +674,34 @@
|
||||
<string name="show_npub_as_a_qr_code">แสดง npub เป็น qr code</string>
|
||||
<string name="invalid_nip19_uri">ที่อยู่ไม่ถูกต้อง</string>
|
||||
<string name="invalid_nip19_uri_description">Amethyst ได้รับ URI แต่ URI นั้นไม่ถูกต้อง: %1$s</string>
|
||||
<string name="dm_relays_title">รีเลย์ขาเข้าของ DM</string>
|
||||
<string name="dm_relays_through">รีเลย์: %1$s</string>
|
||||
<string name="dm_relays_regular">ใช้รีเลย์ทั่วไป</string>
|
||||
<string name="dm_relays_not_found">ตั้งค่ารีเลย์ส่วนตัวสำหรับรับข้อมูล</string>
|
||||
<string name="dm_relays_not_found_description">การตั้งค่านี้จะแจ้งให้ทุกคนทราบว่าควรใช้รีเลย์ใดในการส่งข้อความถึงคุณ หากไม่มีการตั้งค่านี้ คุณอาจพลาดข้อความบางส่วนได้</string>
|
||||
<string name="dm_relays_not_found_examples">ตัวเลือกที่ดี ได้แก่:\n- inbox.nostr.wine (เสียค่าบริการ)\n- you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ)</string>
|
||||
<string name="dm_relays_not_found_editing">ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น</string>
|
||||
<string name="dm_relays_not_found_create_now">ตั้งค่าตอนนี้เลย</string>
|
||||
<string name="search_relays_title">ค้นหารีเลย์</string>
|
||||
<string name="search_relays_not_found">ตั้งค่ารีเลย์เพื่อใช้ค้นหา</string>
|
||||
<string name="search_relays_not_found_description">การสร้างรายชื่อรีเลย์ที่ออกแบบมาเฉพาะสำหรับการค้นหาและการแท็กผู้ใช้จะช่วยปรับปรุงผลลัพธ์เหล่านี้ได้</string>
|
||||
<string name="search_relays_not_found_editing">ใส่รีเลย์ 1-3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50</string>
|
||||
<string name="search_relays_not_found_examples">ตัวเลือกที่ดี ได้แก่:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
|
||||
<string name="relay_settings">ตั้งค่ารีเลย์</string>
|
||||
<string name="public_home_section">รีเลย์สาธารณะสำหรับหน้า Home</string>
|
||||
<string name="public_home_section_explainer">รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1-3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้</string>
|
||||
<string name="public_notif_section">รีเลย์ขาเข้าสาธารณะ</string>
|
||||
<string name="public_notif_section_explainer">รีเลย์ประเภทนี้จะรับการตอบกลับ ความคิดเห็น การถูกใจ และการส่ง \"zap\" ทั้งหมดไปยังโพสต์ของคุณ ใส่รีเลย์ 1-3 ตัว และตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้ยอมรับโพสต์จากทุกคน</string>
|
||||
<string name="private_inbox_section">รีเลย์ขาเข้าของ DM</string>
|
||||
<string name="private_inbox_section_explainer">ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ คนอื่นจะใช้รีเลย์เหล่านี้ในการส่งข้อความ DM ถึงคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตัวเลือกที่ดี ได้แก่:\n - inbox.nostr.wine (เสียค่าบริการ)\n - you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ)</string>
|
||||
<string name="private_outbox_section">รีเลย์ส่วนตัว</string>
|
||||
<string name="private_outbox_section_explainer">ใส่รีเลย์ 1-3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน</string>
|
||||
<string name="kind_3_section">รีเลย์ทั่วไป</string>
|
||||
<string name="kind_3_section_description">Amethyst ใช้รีเลย์เหล่านี้เพื่อดาวน์โหลดโพสต์สำหรับคุณ</string>
|
||||
<string name="search_section">ค้นหารีเลย์</string>
|
||||
<string name="search_section_explainer">รายการรีเลย์ที่จะใช้เมื่อค้นหาข้อมูลหรือผู้ใช้ การแท็กและการค้นหาจะไม่ทำงานหากไม่มีตัวเลือก ตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้รองรับ NIP-50</string>
|
||||
<string name="local_section">รีเลย์ในพื้นที่ของคุณ</string>
|
||||
<string name="local_section_explainer">รายการรีเลย์ที่กำลังทำงานอยู่ในอุปกรณ์นี้</string>
|
||||
<string name="zap_the_devs_title">zap ให้นักพัฒนา</string>
|
||||
<string name="zap_the_devs_description">การบริจาคของคุณช่วยให้เราสร้างความแตกต่าง ทุก sat มีค่า!</string>
|
||||
<string name="donate_now">บริจาค</string>
|
||||
@@ -703,4 +739,9 @@
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">ไม่สามารถ zap โน้ตฉบับร่างได้</string>
|
||||
<string name="draft_note">โน๊ตฉบับร่าง</string>
|
||||
<string name="load_from_text">From Msg</string>
|
||||
<string name="dvm_looking_for_app">กำลังค้นหาแอพพลิเคชั่น Dvm</string>
|
||||
<string name="dvm_waiting_status">ส่งคำขอเรียบร้อย และกำลังรอการตอบกลับ</string>
|
||||
<string name="dvm_requesting_job">กำลังรองานจาก DVM</string>
|
||||
<string name="nwc_payment_request">คำขอชำระเงินถูกส่งแล้ว รอการยืนยันจาก wallet ของคุณ</string>
|
||||
<string name="dvm_waiting_to_confim_payment">รอการยืนยันการชำระเงินหรือผลลัพธ์จาก DVM</string>
|
||||
</resources>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<string name="copy_user_pubkey">复制作者@npub</string>
|
||||
<string name="copy_note_id">复制笔记ID</string>
|
||||
<string name="broadcast">广播</string>
|
||||
<string name="timestamp_it">获取 OpenTimestamps</string>
|
||||
<string name="timestamp_it">获取公开时间戳</string>
|
||||
<string name="timestamp_pending">OpenTimestamps:待确认</string>
|
||||
<string name="timestamp_pending_short">OTS:待定</string>
|
||||
<string name="request_deletion">请求删除</string>
|
||||
@@ -113,6 +113,7 @@
|
||||
<string name="website_url">网站链接</string>
|
||||
<string name="ln_address">闪电地址</string>
|
||||
<string name="ln_url_outdated">LN链接(过期)</string>
|
||||
<string name="save_to_gallery">保存到相册</string>
|
||||
<string name="image_saved_to_the_gallery">图片已保存到相册</string>
|
||||
<string name="failed_to_save_the_image">保存图片失败</string>
|
||||
<string name="upload_image">上传图片</string>
|
||||
@@ -407,6 +408,7 @@
|
||||
<string name="languages">语言</string>
|
||||
<string name="tags">标签</string>
|
||||
<string name="posting_policy">发布政策</string>
|
||||
<string name="relay_error_messages">该中继的错误和通知</string>
|
||||
<string name="message_length">消息长度</string>
|
||||
<string name="subscriptions">订阅</string>
|
||||
<string name="filters">筛选器</string>
|
||||
@@ -500,6 +502,7 @@
|
||||
<string name="load_image_description">何时加载图像</string>
|
||||
<string name="copy_to_clipboard">复制到剪贴板</string>
|
||||
<string name="copy_npub_to_clipboard">复制 npub 到剪贴板</string>
|
||||
<string name="share_or_save">分享或保存</string>
|
||||
<string name="copy_url_to_clipboard">复制链接到剪贴板</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">复制笔记 ID 到剪贴板</string>
|
||||
<string name="created_at">创建于</string>
|
||||
@@ -695,7 +698,7 @@
|
||||
<string name="private_inbox_section">私信收件箱中继</string>
|
||||
<string name="private_inbox_section_explainer">设置 1 ~ 3 个中继作为您的私人收件箱。其他人将使用这些中继向您发送私信。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。示例:\n - inbox.nostr.wine(付费)\n - you.nostr1.com(个人专用中继 - 付费)</string>
|
||||
<string name="private_outbox_section">私人中继</string>
|
||||
<string name="private_outbox_section_explainer">设置 1 ~ 3 个中继用于保存其他人无法看到的事件,例如你的草稿和软件设置。理想情况下,这些应该是本地中继,或者是在获取用户内容时需要经过身份验证的中继。</string>
|
||||
<string name="private_outbox_section_explainer">设置 1 ~ 3 个中继用于保存其他人无法看到的事件,例如您的草稿和软件设置。理想情况下,这些应该是本地中继,或者是在获取用户内容时需要经过身份验证的中继。</string>
|
||||
<string name="kind_3_section">通用中继</string>
|
||||
<string name="kind_3_section_description">Amethyst 会通过这些中继为您获取帖子。</string>
|
||||
<string name="search_section">搜索中继</string>
|
||||
@@ -711,7 +714,7 @@
|
||||
<string name="thank_you">谢谢!</string>
|
||||
<string name="max_limit">最大上限</string>
|
||||
<string name="restricted_writes">写入限制</string>
|
||||
<string name="forked_from"></string>
|
||||
<string name="forked_from">Fork 來源</string>
|
||||
<string name="forked_tag">复刻</string>
|
||||
<string name="git_repository">Git 仓库: %1$s</string>
|
||||
<string name="git_web_address">网址</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user