mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +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 |
+2
-2
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 374
|
||||
versionName "0.87.5"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,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
|
||||
@@ -202,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()
|
||||
@@ -257,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
|
||||
}
|
||||
@@ -265,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,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
|
||||
}
|
||||
@@ -284,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,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
|
||||
}
|
||||
@@ -303,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,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
|
||||
}
|
||||
@@ -322,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,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
|
||||
}
|
||||
@@ -346,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,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) {
|
||||
@@ -1024,7 +1025,9 @@ class Account(
|
||||
LocalCache.justConsume(it, null)
|
||||
Client.send(it)
|
||||
|
||||
pendingAttestations = pendingAttestations - pair.key
|
||||
pendingAttestations.update {
|
||||
it - pair.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1032,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) {
|
||||
@@ -1041,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()
|
||||
}
|
||||
@@ -1311,7 +1316,7 @@ class Account(
|
||||
fun consumeAndSendNip95(
|
||||
data: FileStorageEvent,
|
||||
signedEvent: FileStorageHeaderEvent,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
@@ -1337,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)
|
||||
@@ -1345,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)
|
||||
@@ -1389,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
|
||||
@@ -1424,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?,
|
||||
@@ -1497,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?,
|
||||
@@ -1586,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?,
|
||||
@@ -1654,7 +1659,7 @@ class Account(
|
||||
originalNote: Note,
|
||||
notify: HexKey?,
|
||||
summary: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1684,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?,
|
||||
@@ -2600,7 +2605,7 @@ 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)
|
||||
@@ -2613,24 +2618,24 @@ class Account(
|
||||
?.feedTypes
|
||||
?: FeedType.values().toSet()
|
||||
|
||||
Relay(url, 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(RelayUrlFormatter.normalize(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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -244,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
|
||||
}
|
||||
@@ -253,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
|
||||
}
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ class MultiPlayerPlaybackManager(
|
||||
newPosition: PositionInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
if (mustCachePositions) {
|
||||
if (mustCachePositions && player.playbackState != STATE_IDLE) {
|
||||
cachedPositions.add(uri, newPosition.positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -523,11 +524,11 @@ class Relay(
|
||||
writeToSocket("""["CLOSE","$subscriptionId"]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: Relay): Boolean {
|
||||
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) } }
|
||||
|
||||
@@ -304,6 +304,7 @@ fun EditPostView(
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = 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,6 +251,7 @@ fun ImageVideoPost(
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(
|
||||
videoUri = it.toString(),
|
||||
mimeType = postViewModel.mediaType,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -425,6 +425,7 @@ fun NewPostView(
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -1737,7 +1738,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, 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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -43,16 +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.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,7 +80,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -84,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
|
||||
@@ -95,8 +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.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
|
||||
@@ -117,6 +134,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
title: String? = null,
|
||||
thumbUri: String,
|
||||
authorName: String? = null,
|
||||
@@ -134,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) },
|
||||
)
|
||||
@@ -148,6 +167,7 @@ fun LoadThumbAndThenVideoView(
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
roundedCorner = roundedCorner,
|
||||
@@ -161,6 +181,7 @@ fun LoadThumbAndThenVideoView(
|
||||
} else {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = null,
|
||||
roundedCorner = roundedCorner,
|
||||
@@ -178,11 +199,11 @@ fun LoadThumbAndThenVideoView(
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
@@ -218,21 +239,20 @@ fun VideoView(
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -272,21 +292,20 @@ fun VideoView(
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -297,21 +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,
|
||||
isFiniteHeight: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
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 ->
|
||||
@@ -322,13 +340,13 @@ fun VideoViewInner(
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
@@ -336,6 +354,7 @@ fun VideoViewInner(
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -353,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
|
||||
}
|
||||
@@ -496,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,11 +581,20 @@ fun GetVideoController(
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.let {
|
||||
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
|
||||
it.stop()
|
||||
it.release()
|
||||
controller.value = null
|
||||
// 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()
|
||||
Log.d("PlaybackService", "Released Video from Pause $videoUri ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,13 +681,13 @@ data class VideoThumb(
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun RenderVideoPlayer(
|
||||
videoUri: String,
|
||||
mimeType: String?,
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
nostrUriCallback: String?,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
@@ -661,6 +695,7 @@ private fun RenderVideoPlayer(
|
||||
modifier: Modifier,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
|
||||
|
||||
@@ -724,6 +759,7 @@ private fun RenderVideoPlayer(
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
startingMuteState,
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
@@ -741,7 +777,7 @@ private fun RenderVideoPlayer(
|
||||
KeepPlayingButton(
|
||||
keepPlaying,
|
||||
controllerVisible,
|
||||
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.
|
||||
@@ -759,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,7 +821,7 @@ fun Waveform(
|
||||
controller: MediaController,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val waveformProgress = remember { mutableStateOf(0F) }
|
||||
val waveformProgress = remember { mutableFloatStateOf(0F) }
|
||||
|
||||
DrawWaveform(waveform, waveformProgress, modifier)
|
||||
|
||||
@@ -791,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -926,6 +970,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
modifier: Modifier,
|
||||
toggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val holdOn =
|
||||
@@ -946,6 +991,7 @@ private fun MuteButton(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
@@ -978,14 +1024,14 @@ private fun MuteButton(
|
||||
private fun KeepPlayingButton(
|
||||
keepPlayingStart: MutableState<Boolean>,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
alignment: Modifier,
|
||||
modifier: Modifier,
|
||||
toggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = alignment,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
@@ -1013,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-381
@@ -23,50 +23,27 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
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.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -76,7 +53,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
@@ -87,19 +63,15 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
@@ -110,24 +82,18 @@ 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.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
@@ -139,11 +105,8 @@ 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
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableContentView(
|
||||
content: BaseMediaContent,
|
||||
images: ImmutableList<BaseMediaContent> = remember(content) { listOf(content).toImmutableList() },
|
||||
@@ -154,13 +117,6 @@ fun ZoomableContentView(
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember(content) { mutableStateOf(false) }
|
||||
|
||||
// store the dialog open or close state
|
||||
val shareOpen = remember { mutableStateOf(false) }
|
||||
|
||||
if (shareOpen.value) {
|
||||
ShareImageAction(shareOpen, content) { shareOpen.value = false }
|
||||
}
|
||||
|
||||
var mainImageModifier =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
@@ -170,15 +126,13 @@ fun ZoomableContentView(
|
||||
|
||||
if (content is MediaUrlContent) {
|
||||
mainImageModifier =
|
||||
mainImageModifier.combinedClickable(
|
||||
mainImageModifier.clickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { shareOpen.value = true },
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
mainImageModifier =
|
||||
mainImageModifier.combinedClickable(
|
||||
mainImageModifier.clickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { shareOpen.value = true },
|
||||
)
|
||||
} else {
|
||||
mainImageModifier = mainImageModifier.clickable { dialogOpen = true }
|
||||
@@ -187,13 +141,16 @@ fun ZoomableContentView(
|
||||
when (content) {
|
||||
is MediaUrlImage ->
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
UrlImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel)
|
||||
TwoSecondController(content) { controllerVisible ->
|
||||
UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
is MediaUrlVideo ->
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
@@ -208,12 +165,15 @@ fun ZoomableContentView(
|
||||
}
|
||||
}
|
||||
is MediaLocalImage ->
|
||||
LocalImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel)
|
||||
TwoSecondController(content) { controllerVisible ->
|
||||
LocalImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
|
||||
}
|
||||
is MediaLocalVideo ->
|
||||
content.localFile?.let {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
VideoView(
|
||||
videoUri = it.toUri().toString(),
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
@@ -233,11 +193,30 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
fun TwoSecondController(
|
||||
content: BaseMediaContent,
|
||||
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
|
||||
) {
|
||||
val controllerVisible = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(content) {
|
||||
launch(Dispatchers.Default) {
|
||||
delay(2000)
|
||||
withContext(Dispatchers.Main) {
|
||||
controllerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner(controllerVisible)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocalImageView(
|
||||
content: MediaLocalImage,
|
||||
mainImageModifier: Modifier,
|
||||
isFiniteHeight: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
) {
|
||||
@@ -294,15 +273,15 @@ private fun LocalImageView(
|
||||
SubcomposeAsyncImageContent()
|
||||
|
||||
content.isVerified?.let {
|
||||
val verifierModifier =
|
||||
if (topPaddingForControllers.isSpecified) {
|
||||
Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = Modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) {
|
||||
HashVerificationSymbol(it)
|
||||
}
|
||||
|
||||
Box(verifierModifier, contentAlignment = Alignment.TopEnd) {
|
||||
HashVerificationSymbol(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,11 +315,11 @@ private fun LocalImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
fun UrlImageView(
|
||||
content: MediaUrlImage,
|
||||
mainImageModifier: Modifier,
|
||||
isFiniteHeight: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
) {
|
||||
@@ -392,15 +371,15 @@ private fun UrlImageView(
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
SubcomposeAsyncImageContent()
|
||||
|
||||
val verifierModifier =
|
||||
if (topPaddingForControllers.isSpecified) {
|
||||
Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) {
|
||||
ShowHash(content)
|
||||
}
|
||||
|
||||
Box(verifierModifier, contentAlignment = Alignment.TopEnd) {
|
||||
ShowHash(content)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
@@ -634,217 +613,35 @@ fun DisplayBlurHash(
|
||||
}
|
||||
|
||||
@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(
|
||||
android.view.WindowInsets.Type.systemBars(),
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.show(
|
||||
android.view.WindowInsets.Type.systemBars(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState { allImages.size }
|
||||
val controllerVisible = remember { mutableStateOf(false) }
|
||||
val holdOn = remember { mutableStateOf<Boolean>(true) }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
launch(Dispatchers.Default) {
|
||||
delay(2000)
|
||||
holdOn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
allImages.getOrNull(index)?.let {
|
||||
RenderImageOrVideo(
|
||||
content = it,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RenderImageOrVideo(
|
||||
content = imageUrl,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CloseButton(onPress = onDismiss)
|
||||
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
if (myContent is MediaUrlContent) {
|
||||
Row {
|
||||
CopyToClipboard(content = myContent)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
SaveToGallery(url = myContent.url)
|
||||
}
|
||||
} else if (myContent is MediaLocalImage && myContent.localFileExists()) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile!!,
|
||||
mimeType = myContent.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 CopyToClipboard(content: BaseMediaContent) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(horizontal = Size5dp),
|
||||
onClick = { popupExpanded.value = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.copy_url_to_clipboard),
|
||||
)
|
||||
|
||||
ShareImageAction(popupExpanded, content) { popupExpanded.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareImageAction(
|
||||
fun ShareImageAction(
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
content: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (content is MediaUrlContent) {
|
||||
ShareImageAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.url,
|
||||
postNostrUri = content.uri,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
ShareImageAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.localFile?.toUri().toString(),
|
||||
postNostrUri = content.uri,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun ShareImageAction(
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
videoUri: String?,
|
||||
postNostrUri: String?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
@@ -852,30 +649,21 @@ private fun ShareImageAction(
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (content is MediaUrlContent) {
|
||||
if (videoUri != null && !videoUri.startsWith("file")) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(content.url))
|
||||
clipboardManager.setText(AnnotatedString(videoUri))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
content.uri?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content is MediaPreloadedContent) {
|
||||
postNostrUri?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(content.uri))
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
@@ -883,93 +671,6 @@ private fun ShareImageAction(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(
|
||||
content: BaseMediaContent,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
|
||||
|
||||
if (content is MediaUrlImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
UrlImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
)
|
||||
}
|
||||
} else if (content is MediaLocalImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LocalImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,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)
|
||||
|
||||
@@ -142,6 +142,7 @@ fun AudioTrackHeader(
|
||||
cover?.let { cover ->
|
||||
LoadThumbAndThenVideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = noteEvent.subject(),
|
||||
thumbUri = cover,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
@@ -153,6 +154,7 @@ fun AudioTrackHeader(
|
||||
} ?: run {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
@@ -202,6 +204,7 @@ fun AudioHeader(
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
waveform = waveform,
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
|
||||
@@ -52,6 +52,7 @@ fun FileHeaderDisplay(
|
||||
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) {
|
||||
@@ -62,6 +63,7 @@ fun FileHeaderDisplay(
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -72,6 +74,7 @@ fun FileHeaderDisplay(
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -152,6 +152,7 @@ fun RenderLiveActivityEventInner(
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
mimeType = null,
|
||||
title = subject,
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
|
||||
@@ -87,6 +87,7 @@ fun VideoDisplay(
|
||||
val description = event.content.ifBlank { 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) {
|
||||
@@ -97,6 +98,7 @@ fun VideoDisplay(
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -107,6 +109,7 @@ fun VideoDisplay(
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
artworkUri = event.thumb() ?: event.image(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ fun JustVideoDisplay(
|
||||
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) {
|
||||
@@ -62,6 +63,7 @@ fun JustVideoDisplay(
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -72,6 +74,7 @@ fun JustVideoDisplay(
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -254,7 +254,7 @@ private fun FeedLoaded(
|
||||
NoteCompose(
|
||||
item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = state.showHidden.value,
|
||||
quotesLeft = 3,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -499,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>
|
||||
@@ -504,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>
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<string name="website_url">जालस्थान पता</string>
|
||||
<string name="ln_address">लै॰जाल पता</string>
|
||||
<string name="ln_url_outdated">लै॰जाल पता (पुराना)</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">टाँकाफलक में एनपुब॰ की अनुकृति करें</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>
|
||||
|
||||
@@ -406,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -499,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>
|
||||
@@ -498,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>
|
||||
|
||||
@@ -113,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>
|
||||
@@ -406,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>
|
||||
@@ -499,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -501,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>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<string name="website_url">Website URL</string>
|
||||
<string name="ln_address">LN Address</string>
|
||||
<string name="ln_url_outdated">LN URL (outdated)</string>
|
||||
<string name="save_to_gallery">Save to Gallery</string>
|
||||
<string name="image_saved_to_the_gallery">Image saved to the gallery</string>
|
||||
<string name="failed_to_save_the_image">Failed to save the image</string>
|
||||
<string name="upload_image">Upload Image</string>
|
||||
@@ -580,6 +581,7 @@
|
||||
|
||||
<string name="copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
|
||||
<string name="share_or_save">Share or Save</string>
|
||||
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
|
||||
|
||||
@@ -823,7 +825,7 @@
|
||||
<string name="public_home_section">Public Home Relays</string>
|
||||
<string name="public_home_section_explainer">This relay type stores all your content. Amethyst will send your posts here and others will use these relays to find your content. Insert between 1–3 relays. They can be personal relays, paid relays or public relays.</string>
|
||||
<string name="public_notif_section">Public Inbox Relays</string>
|
||||
<string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. Insert between 1–3 relays and make sure they accept posts from anyone.</string>
|
||||
<string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. They can be paid or free relays. Limits set by the relay operator can limit the notifications you receive for the good and for the bad. For example, if you are being attacked by comment spam, paid relays can filter the spam out. Insert between 1–3 relays.</string>
|
||||
<string name="private_inbox_section">DM Inbox Relays</string>
|
||||
<string name="private_inbox_section_explainer">Insert between 1–3 relays to serve as your private inbox. Others will use these relays to send DMs to you. DM Inbox relays should accept any message from anyone, but only allow you to download them. Good options are:\n - inbox.nostr.wine (paid)\n - you.nostr1.com (personal relays - paid)</string>
|
||||
<string name="private_outbox_section">Private Relays</string>
|
||||
|
||||
+7
-4
@@ -62,6 +62,7 @@ class RichTextParserBenchmark {
|
||||
RichTextParser().parseText(
|
||||
"first https://m.primal.net/HeKw.jpg second",
|
||||
EmptyTagList,
|
||||
null,
|
||||
).paragraphs[0].words[1] is ImageSegment,
|
||||
)
|
||||
}
|
||||
@@ -74,6 +75,7 @@ class RichTextParserBenchmark {
|
||||
RichTextParser().parseText(
|
||||
"first amethyst.social second",
|
||||
EmptyTagList,
|
||||
null,
|
||||
).paragraphs[0].words[1] is LinkSegment,
|
||||
)
|
||||
}
|
||||
@@ -86,6 +88,7 @@ class RichTextParserBenchmark {
|
||||
RichTextParser().parseText(
|
||||
"first #amethyst second",
|
||||
EmptyTagList,
|
||||
null,
|
||||
).paragraphs[0].words[1] is HashTagSegment,
|
||||
)
|
||||
}
|
||||
@@ -94,14 +97,14 @@ class RichTextParserBenchmark {
|
||||
@Test
|
||||
fun computeTestCase1All() {
|
||||
benchmarkRule.measureRepeated {
|
||||
RichTextParser().parseText(testCase1, EmptyTagList)
|
||||
RichTextParser().parseText(testCase1, EmptyTagList, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeTestCase2All() {
|
||||
benchmarkRule.measureRepeated {
|
||||
RichTextParser().parseText(testCase2, EmptyTagList)
|
||||
RichTextParser().parseText(testCase2, EmptyTagList, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +132,14 @@ class RichTextParserBenchmark {
|
||||
@Test
|
||||
fun computeTestCase3All() {
|
||||
benchmarkRule.measureRepeated {
|
||||
RichTextParser().parseText(testCase3, EmptyTagList)
|
||||
RichTextParser().parseText(testCase3, EmptyTagList, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeTestCaseJapanese() {
|
||||
benchmarkRule.measureRepeated {
|
||||
RichTextParser().parseText(testCaseJapanese, testCaseJapaneseTags)
|
||||
RichTextParser().parseText(testCaseJapanese, testCaseJapaneseTags, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -38,6 +38,7 @@ abstract class MediaUrlContent(
|
||||
dim: String? = null,
|
||||
blurhash: String? = null,
|
||||
val uri: String? = null,
|
||||
val mimeType: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash)
|
||||
|
||||
@Immutable
|
||||
@@ -49,7 +50,8 @@ class MediaUrlImage(
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri)
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
@Immutable
|
||||
class MediaUrlVideo(
|
||||
@@ -62,7 +64,8 @@ class MediaUrlVideo(
|
||||
val authorName: String? = null,
|
||||
blurhash: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri)
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
@Immutable
|
||||
abstract class MediaPreloadedContent(
|
||||
|
||||
@@ -61,6 +61,7 @@ class RichTextParser() {
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
uri = callbackUri,
|
||||
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||
@@ -73,6 +74,7 @@ class RichTextParser() {
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
uri = callbackUri,
|
||||
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -82,6 +82,8 @@ class ContactListEvent(
|
||||
|
||||
fun unverifiedFollowTagSet() = tags.filter { it.size > 1 && it[0] == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun countFollowTags() = tags.count { it.size > 1 && it[0] == "t" }
|
||||
|
||||
fun unverifiedFollowGeohashSet() = tags.filter { it.size > 1 && it[0] == "g" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun unverifiedFollowAddressSet() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
Reference in New Issue
Block a user