Compare commits

...

94 Commits

Author SHA1 Message Date
Vitor Pamplona
609faf245a Reverts save button 2024-06-10 20:01:37 -04:00
Vitor Pamplona
bfd28c4b44 Reverts save button 2024-06-10 20:01:11 -04:00
Vitor Pamplona
70a8488f12 Merge pull request #907 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 19:59:47 -04:00
Crowdin Bot
cda6e4722c New Crowdin translations by GitHub Action 2024-06-10 23:54:07 +00:00
Vitor Pamplona
c4ecf85618 Reverting the spotlight on the Save button 2024-06-10 19:51:52 -04:00
Vitor Pamplona
6ed1f9d369 Merge pull request #906 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 09:59:32 -04:00
Crowdin Bot
aea765ec54 New Crowdin translations by GitHub Action 2024-06-10 13:52:45 +00:00
Vitor Pamplona
165c3bb852 Fixes not centered Blank Notes 2024-06-10 09:50:37 -04:00
Vitor Pamplona
2e9683491f Fixes scrollable drawer for all screen sizes 2024-06-10 09:50:24 -04:00
Vitor Pamplona
eea8b99622 v0.87.6 2024-06-07 20:16:52 -04:00
Vitor Pamplona
0db6a0d27b Fixes relay drops and difficulty in connecting.
Refactors the use of relay objects to avoid creating full state relay connections only once
2024-06-07 20:13:15 -04:00
Vitor Pamplona
723e575b4b Fixes the LazyColumn for Hashtags on Profile 2024-06-07 19:28:20 -04:00
Vitor Pamplona
b182d409b6 Fixes benchmark tests 2024-06-07 18:50:32 -04:00
Vitor Pamplona
043e37030e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-07 13:46:11 -04:00
Vitor Pamplona
8d730040b8 Improved description for public inbox 2024-06-07 13:46:04 -04:00
Vitor Pamplona
794a300c74 Merge pull request #904 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-07 12:42:58 -04:00
Crowdin Bot
91fd490ab7 New Crowdin translations by GitHub Action 2024-06-07 15:54:44 +00:00
Vitor Pamplona
0e1de03db8 Only updates video position when the video is ready to play 2024-06-07 11:52:39 -04:00
Vitor Pamplona
7906a94ca0 Moves the video release to a thread without damaging ON_PAUSE, ON_RELEASE order. 2024-06-07 11:42:15 -04:00
Vitor Pamplona
6ce019a86a Larger search limits for profiles 2024-06-07 10:37:13 -04:00
Vitor Pamplona
d8939779e8 Improves UI buttons of the Image Dialog 2024-06-07 10:36:56 -04:00
Vitor Pamplona
313d87a58b Removes debug marker 2024-06-06 18:43:11 -04:00
Vitor Pamplona
057e61bfe1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-06 18:39:48 -04:00
Vitor Pamplona
c94c882c3a restructures video and image views to avoid buttons getting on top of one another. 2024-06-06 18:39:39 -04:00
Vitor Pamplona
673f632563 Refactoring 2024-06-06 12:43:59 -04:00
Vitor Pamplona
1a56b2bf68 Log messages when saving to disk. 2024-06-05 18:21:20 -04:00
Vitor Pamplona
2e9964e0e8 Merge pull request #903 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-05 16:37:28 -04:00
Crowdin Bot
b80eb32991 New Crowdin translations by GitHub Action 2024-06-05 20:30:27 +00:00
Vitor Pamplona
ad281c7607 BugFix crashing with old keyboards: Forces transformations to have middle positions before the last cursor position. 2024-06-05 16:28:44 -04:00
Vitor Pamplona
3c1a918b2d Moving attestations local cache to a flow 2024-06-05 13:24:31 -04:00
Vitor Pamplona
53e6677558 Adds Hashtag search and display to Reposted Notes. 2024-06-05 11:58:02 -04:00
Vitor Pamplona
b8deb34544 Avoid using DM relays to get chats 2024-06-05 09:34:03 -04:00
Vitor Pamplona
d9cffa87ef When broadcasting privately, avoids keeping the connection open for other activities. 2024-06-05 08:33:11 -04:00
Vitor Pamplona
ae418f9d45 v0.87.5 2024-06-04 18:46:45 -04:00
Vitor Pamplona
d10b4c6bde - Re-normalizes all relays urls before connecting to reduce duplications
- Normalizes all new relays in the edit screens.
2024-06-04 18:30:01 -04:00
Vitor Pamplona
f1e516662c Adds flare.pub videos to the media tab on Amethyst 2024-06-04 17:12:14 -04:00
Vitor Pamplona
1bf8165641 Improving the detection of image types on the Media Feed. 2024-06-04 16:38:31 -04:00
Vitor Pamplona
807af3b613 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-04 16:01:50 -04:00
Vitor Pamplona
e36ec0a0a3 removes extra char on xml 2024-06-04 16:01:37 -04:00
Vitor Pamplona
3172b69271 Merge pull request #902 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-04 15:55:27 -04:00
Crowdin Bot
81ee13bebc New Crowdin translations by GitHub Action 2024-06-04 19:47:45 +00:00
Vitor Pamplona
a6a8138c7f - Performance Improvements: Simplifies intrinsic size calculations for Image Previews and Videos
- Improves rendering of chat messages
- Fixes the jittering from resizing Videos and Images during loading.
- Fixes video cut off when loading
- Fixes blurhash covering videos in landscape
- Fixes landscape video centralization
- Fixes chat using Two Pane layout for landscape and regular for portrait
2024-06-04 15:45:59 -04:00
Vitor Pamplona
16142f61b8 normalization refactoring 2024-06-04 10:54:06 -04:00
Vitor Pamplona
7193228dd2 Fixes size of the incognitoIcon on chat messages 2024-06-03 19:32:46 -04:00
Vitor Pamplona
29b8f6916b v0.87.4 2024-06-03 17:29:43 -04:00
Vitor Pamplona
a6519d57c6 Passes the video note uri so that when the user clicks in the video popup the app brings it back to the playing screen without breaking the video. 2024-06-03 17:26:40 -04:00
Vitor Pamplona
2b2ee5bbfc Avoids using Video Cache for live streams. 2024-06-03 17:18:38 -04:00
Vitor Pamplona
62a6b7b7e0 Fixes video stream position for live streams. 2024-06-03 17:18:17 -04:00
Vitor Pamplona
5e0a1f9de1 Moves SimpleCache creation to the IO thread. 2024-06-03 17:17:26 -04:00
Vitor Pamplona
571b1bc9cf Merge pull request #900 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 14:27:47 -04:00
Crowdin Bot
a3e76225e6 New Crowdin translations by GitHub Action 2024-06-03 18:26:14 +00:00
Vitor Pamplona
4388ceb529 v0.87.3 2024-06-03 14:24:28 -04:00
Vitor Pamplona
5d4fa0d732 Adds the incoming relays to the request. 2024-06-03 14:15:12 -04:00
Vitor Pamplona
ed6825de97 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 14:04:46 -04:00
Vitor Pamplona
10f623b6b3 Forces URLs to be single line. 2024-06-03 14:04:38 -04:00
Vitor Pamplona
467e5c0e4a Fixes bug that every socket is null 2024-06-03 14:04:29 -04:00
Vitor Pamplona
09d2e9593e Adds error messages to relay information dialog 2024-06-03 14:03:58 -04:00
Vitor Pamplona
cd20c0567b Merge pull request #899 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 13:27:05 -04:00
Vitor Pamplona
db15f5e2a6 Uses the user to avoid Java removing the variable due to lack of usage. 2024-06-03 13:25:56 -04:00
Crowdin Bot
18d5c7e3f4 New Crowdin translations by GitHub Action 2024-06-03 16:08:05 +00:00
Vitor Pamplona
d42ae7c9f7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 12:06:38 -04:00
Vitor Pamplona
2b741b62e8 Merge pull request #898 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 12:06:14 -04:00
Vitor Pamplona
4e1c63d36a allows more replies to filter when using DVMs 2024-06-03 12:05:58 -04:00
Vitor Pamplona
95797171d7 Fixes observer of authors 2024-06-03 12:05:46 -04:00
Crowdin Bot
395ef9695e New Crowdin translations by GitHub Action 2024-06-03 15:49:13 +00:00
Vitor Pamplona
ad1c7088c7 Loads the DVM's NIP65 records. 2024-06-03 11:47:39 -04:00
Vitor Pamplona
eda4569b64 Only shows debug relay writes in DEBUG 2024-06-03 11:47:20 -04:00
Vitor Pamplona
fb5f1e738b Adds a user and max result params to the DVM request. 2024-06-03 11:47:00 -04:00
Vitor Pamplona
e3cca6c691 Pulls NIP-65 relays even in the first lookup for the user. 2024-06-03 11:45:57 -04:00
Vitor Pamplona
ce2b8dfa11 Sends the DVM request to the NIP65 Inbox relays. 2024-06-03 11:15:36 -04:00
Vitor Pamplona
b5faf6ffc5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 11:15:07 -04:00
Vitor Pamplona
4ee157f27f Merge pull request #897 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 11:14:50 -04:00
Vitor Pamplona
ad8c04b79a Loads user's relays on scrolling. 2024-06-03 11:14:33 -04:00
Crowdin Bot
c0e6273954 New Crowdin translations by GitHub Action 2024-06-03 15:04:13 +00:00
Vitor Pamplona
aebade21a6 Don't display zap splits when the post is short. 2024-06-03 11:02:23 -04:00
Vitor Pamplona
bda24c0101 Separates the Account's relay info to the UI component 2024-06-03 11:01:34 -04:00
Vitor Pamplona
3d0b461550 Moves Relay Stats to it's own cache.
Refactors the isLocalRelay function
Removes unnecessary onError methods from the Relay class
Moves byte counts from Int to Long
2024-06-03 10:42:00 -04:00
Vitor Pamplona
5900e6c49f Merge pull request #896 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 08:45:33 -04:00
Crowdin Bot
f29fe3ced5 New Crowdin translations by GitHub Action 2024-06-03 12:38:46 +00:00
Vitor Pamplona
bf17cc8f03 Updating Firebase and Androidx JUnit 2024-06-03 08:36:45 -04:00
Vitor Pamplona
a8d10c22d4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-01 16:14:47 -04:00
Vitor Pamplona
5492ebf43f Merge pull request #893 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 16:13:05 -04:00
Vitor Pamplona
0c48366d56 v0.80.2 2024-06-01 16:12:49 -04:00
Crowdin Bot
d66a027270 New Crowdin translations by GitHub Action 2024-06-01 19:33:36 +00:00
Vitor Pamplona
f1834af506 Moves the filter for no subscriptions to the DAL 2024-06-01 15:31:43 -04:00
Vitor Pamplona
f26fa71e49 improves the speed of the kind filter for DVMs 2024-06-01 15:20:26 -04:00
Vitor Pamplona
4faee68174 Filters by supported kinds in the DVM view. 2024-06-01 15:16:35 -04:00
Vitor Pamplona
2326e66382 Solves race conditions when connecting to relays. 2024-06-01 15:16:18 -04:00
Vitor Pamplona
bf71365f51 Merge pull request #892 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 13:18:30 -04:00
Crowdin Bot
f45e8d491c New Crowdin translations by GitHub Action 2024-06-01 17:17:09 +00:00
Vitor Pamplona
5a476cab32 v0.87.0 2024-06-01 13:15:27 -04:00
Vitor Pamplona
b5b0a19df5 Fixes the disappearance of the notification icon 2024-06-01 13:14:30 -04:00
Vitor Pamplona
85d26dee08 Merge pull request #891 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 10:58:27 -04:00
Crowdin Bot
476da07d3d New Crowdin translations by GitHub Action 2024-06-01 14:55:45 +00:00
123 changed files with 3290 additions and 1954 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 369
versionName "0.87.0"
versionCode 376
versionName "0.87.7"
buildConfigField "String", "RELEASE_NOTES_ID", "\"863124b6592359494396c22b917f9d251ad12ac31b09ea29e0265e3c088ce9f8\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -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

View File

@@ -36,6 +36,7 @@ fun TranslatableRichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
@@ -46,6 +47,7 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
id,
callbackUri,
accountViewModel,
nav,
)

View File

@@ -40,6 +40,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
import kotlin.time.measureTimedValue
@@ -53,7 +54,9 @@ class Amethyst : Application() {
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
runBlocking {
newCache.initFileCache(this@Amethyst)
}
newCache
}

View File

@@ -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
}
}
}
}

View File

@@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
@@ -125,6 +126,7 @@ import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -201,7 +203,7 @@ class Account(
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var hasDonatedInVersion: Set<String> = setOf<String>(),
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
var pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) {
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
@@ -238,10 +240,16 @@ class Account(
userProfile().flow().relays.stateFlow,
) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
val baseRelaySet = activeRelays() ?: convertLocalRelays()
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet
val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays() ?: emptySet()
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: Constants.defaultSearchRelaySet
val nip65RelaySet =
(nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()?.map {
AdvertisedRelayListEvent.AdvertisedRelayInfo(
RelayUrlFormatter.normalize(it.relayUrl),
it.type,
)
}
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
// ------
// DMs
@@ -250,7 +258,7 @@ class Account(
var mappedRelaySet =
baseRelaySet.map {
if (newDMRelaySet.contains(it.url)) {
Relay(it.url, true, true, it.activeTypes + FeedType.PRIVATE_DMS)
RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS)
} else {
it
}
@@ -258,7 +266,7 @@ class Account(
newDMRelaySet.forEach { newUrl ->
if (mappedRelaySet.none { it.url == newUrl }) {
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.PRIVATE_DMS))
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.PRIVATE_DMS))
}
}
@@ -269,7 +277,7 @@ class Account(
mappedRelaySet =
mappedRelaySet.map {
if (searchRelaySet.contains(it.url)) {
Relay(it.url, true, it.write || false, it.activeTypes + FeedType.SEARCH)
RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH)
} else {
it
}
@@ -277,7 +285,7 @@ class Account(
searchRelaySet.forEach { newUrl ->
if (mappedRelaySet.none { it.url == newUrl }) {
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, false, setOf(FeedType.SEARCH))
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, false, setOf(FeedType.SEARCH))
}
}
@@ -288,7 +296,7 @@ class Account(
mappedRelaySet =
mappedRelaySet.map {
if (privateOutboxRelaySet.contains(it.url)) {
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
} else {
it
}
@@ -296,7 +304,7 @@ class Account(
privateOutboxRelaySet.forEach { newUrl ->
if (mappedRelaySet.none { it.url == newUrl }) {
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
}
}
@@ -307,7 +315,7 @@ class Account(
mappedRelaySet =
mappedRelaySet.map {
if (localRelayServers.contains(it.url)) {
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
} else {
it
}
@@ -315,7 +323,7 @@ class Account(
localRelayServers.forEach { newUrl ->
if (mappedRelaySet.none { it.url == newUrl }) {
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
}
}
@@ -329,7 +337,7 @@ class Account(
if (nip65setup != null) {
val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
Relay(relay.url, true, relay.write || write, relay.activeTypes + setOf(FeedType.FOLLOWS, FeedType.GLOBAL, FeedType.PUBLIC_CHATS))
RelaySetupInfo(relay.url, true, relay.write || write, relay.feedTypes + setOf(FeedType.FOLLOWS, FeedType.GLOBAL, FeedType.PUBLIC_CHATS))
} else {
relay
}
@@ -339,7 +347,7 @@ class Account(
if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) {
val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
mappedRelaySet = mappedRelaySet + Relay(newNip65Setup.relayUrl, true, write, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS))
mappedRelaySet = mappedRelaySet + RelaySetupInfo(newNip65Setup.relayUrl, true, write, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS))
}
}
@@ -781,10 +789,7 @@ class Account(
note.event?.let { event ->
LnZapRequestEvent.create(
event,
relays =
getNIP65RelayList()?.readRelays()?.toSet()
?: userProfile().latestContactList?.relays()?.keys?.ifEmpty { null }
?: localRelays.map { it.url }.toSet(),
relays = getReceivingRelays(),
signer,
pollOption,
message,
@@ -795,6 +800,12 @@ class Account(
}
}
fun getReceivingRelays(): Set<String> {
return getNIP65RelayList()?.readRelays()?.toSet()
?: userProfile().latestContactList?.relays()?.filter { it.value.read }?.keys?.ifEmpty { null }
?: localRelays.filter { it.read }.map { it.url }.toSet()
}
fun hasWalletConnectSetup(): Boolean {
return zapPaymentRequest != null
}
@@ -1004,9 +1015,9 @@ class Account(
}
suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
Log.d("Pending Attestations", "Updating ${pendingAttestations.value.size} pending attestations")
pendingAttestations.toMap().forEach { pair ->
pendingAttestations.value.forEach { pair ->
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
if (pair.value != newAttestation) {
@@ -1014,7 +1025,9 @@ class Account(
LocalCache.justConsume(it, null)
Client.send(it)
pendingAttestations = pendingAttestations - pair.key
pendingAttestations.update {
it - pair.key
}
}
}
}
@@ -1022,7 +1035,7 @@ class Account(
fun hasPendingAttestations(note: Note): Boolean {
val id = note.event?.id() ?: note.idHex
return pendingAttestations.get(id) != null
return pendingAttestations.value[id] != null
}
fun timestamp(note: Note) {
@@ -1031,7 +1044,9 @@ class Account(
val id = note.event?.id() ?: note.idHex
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
pendingAttestations.update {
it + Pair(id, OtsEvent.stamp(id))
}
saveable.invalidateData()
}
@@ -1301,7 +1316,7 @@ class Account(
fun consumeAndSendNip95(
data: FileStorageEvent,
signedEvent: FileStorageHeaderEvent,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
): Note? {
if (!isWriteable()) return null
@@ -1327,7 +1342,7 @@ class Account(
fun sendNip95(
data: FileStorageEvent,
signedEvent: FileStorageHeaderEvent,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
) {
Client.send(data, relayList = relayList)
Client.send(signedEvent, relayList = relayList)
@@ -1335,7 +1350,7 @@ class Account(
fun sendHeader(
signedEvent: FileHeaderEvent,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
onReady: (Note) -> Unit,
) {
Client.send(signedEvent, relayList = relayList)
@@ -1379,7 +1394,7 @@ class Account(
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
onReady: (Note) -> Unit,
) {
if (!isWriteable()) return
@@ -1414,7 +1429,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
draftTag: String?,
@@ -1487,7 +1502,7 @@ class Account(
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -1576,7 +1591,7 @@ class Account(
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -1644,7 +1659,7 @@ class Account(
originalNote: Note,
notify: HexKey?,
summary: String? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
) {
if (!isWriteable()) return
@@ -1674,7 +1689,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -2462,8 +2477,14 @@ class Account(
dvmPublicKey: String,
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
) {
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer) {
Client.send(it)
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) {
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(dvmPublicKey))?.event as? AdvertisedRelayListEvent)?.readRelays()
if (relayList != null) {
Client.sendPrivately(it, relayList)
} else {
Client.send(it)
}
LocalCache.justConsume(it, null)
onReady(it)
}
@@ -2584,35 +2605,37 @@ class Account(
}
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<Relay>? {
fun activeRelays(): Array<RelaySetupInfo>? {
val usersRelayList =
userProfile().latestContactList?.relays()?.map {
val url = RelayUrlFormatter.normalize(it.key)
val localFeedTypes =
localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes
?: Constants.defaultRelays
.filter { defaultRelay -> defaultRelay.url == it.key }
.filter { defaultRelay -> defaultRelay.url == url }
.firstOrNull()
?.feedTypes
?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
} ?: return null
return usersRelayList.toTypedArray()
}
fun convertLocalRelays(): Array<Relay> {
return localRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray()
fun convertLocalRelays(): Array<RelaySetupInfo> {
return localRelays.map { RelaySetupInfo(RelayUrlFormatter.normalize(it.url), it.read, it.write, it.feedTypes) }.toTypedArray()
}
fun activeGlobalRelays(): Array<String> {
return connectToRelays.value
.filter { it.activeTypes.contains(FeedType.GLOBAL) }
.filter { it.feedTypes.contains(FeedType.GLOBAL) }
.map { it.url }
.toTypedArray()
}
fun activeWriteRelays(): List<Relay> {
fun activeWriteRelays(): List<RelaySetupInfo> {
return connectToRelays.value.filter { it.write }
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
@@ -69,10 +70,6 @@ class AntiSpamFilter {
if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
)
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
@@ -81,6 +78,10 @@ class AntiSpamFilter {
// Log down offenders
logOffender(hash, event)
if (relay != null) {
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
}
liveSpam.invalidateData()
return true
@@ -100,7 +101,7 @@ class AntiSpamFilter {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
spammer.duplicatedMessages += event.id
}
}

View File

@@ -450,7 +450,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -474,6 +473,8 @@ object LocalCache {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
@@ -623,7 +624,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -648,7 +648,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -673,7 +672,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -711,7 +709,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -746,7 +743,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -827,7 +823,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1533,7 +1528,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1572,7 +1566,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}

View File

@@ -28,12 +28,5 @@ data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
)

View File

@@ -31,11 +31,12 @@ object CachedRichTextParser {
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
} else {
val newUrls = RichTextParser().parseText(content, tags)
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(content, newUrls)
newUrls
}

View File

@@ -103,6 +103,16 @@ object HttpClientManager {
}
}
fun getHttpClientForUrl(url: String): OkHttpClient {
// TODO: How to identify relays on the local network?
val isLocalHost = url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")
return if (isLocalHost) {
getHttpClient(false)
} else {
getHttpClient()
}
}
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
return if (useProxy) {
if (this.defaultHttpClient == null) {

View File

@@ -112,9 +112,8 @@ class Nip11Retriever {
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
HttpClientManager.getHttpClientForUrl(dirtyUrl)
.newCall(request)
.enqueue(
object : Callback {

View File

@@ -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),

View File

@@ -92,19 +92,6 @@ abstract class NostrDataSource(val debugName: String) {
}
}
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,

View File

@@ -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,

View File

@@ -138,7 +138,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
limit = 1000,
),
),
TypedFilter(

View File

@@ -178,8 +178,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
limit = 100,
),
),
)
@@ -245,7 +244,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
@@ -254,7 +253,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
addressesToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
@@ -44,7 +45,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
),
@@ -67,7 +68,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),

View File

@@ -28,10 +28,15 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
@@ -40,8 +45,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
var job: Job? = null
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
override fun start() {
job?.cancel()
job =
@@ -68,9 +71,9 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
filter =
JsonFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -89,14 +92,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
@@ -117,14 +120,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =

View File

@@ -86,6 +86,8 @@ class MultiPlayerPlaybackManager(
applicationContext: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
// avoids saving positions for live streams otherwise caching goes crazy
val mustCachePositions = !uri.contains(".m3u8", true)
if (existingSession != null) return existingSession
val player =
@@ -115,7 +117,9 @@ class MultiPlayerPlaybackManager(
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
if (mustCachePositions) {
cachedPositions.add(uri, player.currentPosition)
}
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
@@ -125,20 +129,22 @@ class MultiPlayerPlaybackManager(
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
if (mustCachePositions) {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
@@ -150,7 +156,9 @@ class MultiPlayerPlaybackManager(
newPosition: PositionInfo,
reason: Int,
) {
cachedPositions.add(uri, newPosition.positionMs)
if (mustCachePositions && player.playbackState != STATE_IDLE) {
cachedPositions.add(uri, newPosition.positionMs)
}
}
},
)

View File

@@ -34,41 +34,38 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClientManager
import okhttp3.OkHttpClient
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
@UnstableApi
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory() : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
@UnstableApi
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
@OptIn(UnstableApi::class)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
@OptIn(UnstableApi::class)
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
@OptIn(UnstableApi::class)
override fun getSupportedTypes(): IntArray {
return http.supportedTypes
return nonCachingFactory.supportedTypes
}
@OptIn(UnstableApi::class)
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
return if (mediaItem.mediaId.startsWith("wss")) {
wss.createMediaSource(mediaItem)
} else {
http.createMediaSource(mediaItem)
if (mediaItem.mediaId.contains(".m3u8", true)) {
return nonCachingFactory.createMediaSource(mediaItem)
}
return cachingFactory.createMediaSource(mediaItem)
}
}
@@ -81,7 +78,7 @@ class PlaybackService : MediaSessionService() {
fun newAllInOneDataSource(): MediaSource.Factory {
// This might be needed for live kit.
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
return DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
return CustomMediaSourceFactory()
}
fun lazyDS(): MultiPlayerPlaybackManager {

View File

@@ -27,6 +27,8 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
@@ -41,16 +43,17 @@ class VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun initFileCache(context: Context) {
suspend fun initFileCache(context: Context) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
withContext(Dispatchers.IO) {
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
}
}
// This method should be called when proxy setting changes.

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@@ -41,10 +42,10 @@ object Client : RelayPool.Listener {
@Synchronized
fun reconnect(
relays: Array<Relay>?,
relays: Array<RelaySetupInfo>?,
onlyIfChanged: Boolean = false,
) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.activeTypes.joinToString(",") { it.name } }}")
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}")
checkNotInMainThread()
if (onlyIfChanged) {
@@ -56,10 +57,11 @@ object Client : RelayPool.Listener {
}
if (relays != null) {
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.loadRelays(newRelays)
RelayPool.requestAndWatch()
this.relays = relays
this.relays = newRelays.toTypedArray()
}
}
} else {
@@ -70,15 +72,16 @@ object Client : RelayPool.Listener {
}
if (relays != null) {
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.loadRelays(newRelays)
RelayPool.requestAndWatch()
this.relays = relays
this.relays = newRelays.toTypedArray()
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
fun isSameRelaySetConfig(newRelayConfig: Array<RelaySetupInfo>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
@@ -142,7 +145,7 @@ object Client : RelayPool.Listener {
signedEvent: EventInterface,
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
onDone: (() -> Unit)? = null,
) {
checkNotInMainThread()
@@ -165,7 +168,7 @@ object Client : RelayPool.Listener {
checkNotInMainThread()
relayList.forEach { relayUrl ->
RelayPool.getOrCreateRelay(relayUrl, setOf(FeedType.PRIVATE_DMS), { }) {
RelayPool.getOrCreateRelay(relayUrl, emptySet(), { }) {
it.sendOverride(signedEvent)
}
}
@@ -194,19 +197,6 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange(
type: Relay.StateType,
@@ -284,13 +274,6 @@ object Client : RelayPool.Listener {
afterEOSE: Boolean,
) = Unit
/** A new or repeat message was received */
open fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) = Unit
/** Connected to or disconnected from a relay */
open fun onRelayStateChange(
type: Relay.StateType,

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -36,21 +37,26 @@ object Constants {
val defaultRelays =
arrayOf(
// Free relays for only DMs, Chats and Follows due to the amount of spam
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypes),
// Global
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats),
// Paid relays
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch),
)
val defaultSearchRelaySet = setOf("wss://relay.nostr.band", "wss://nostr.wine", "wss://relay.noswhere.com")
val defaultSearchRelaySet =
setOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
)
}

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
@@ -59,38 +60,24 @@ class Relay(
val write: Boolean = true,
val activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
val brief = RelayBriefInfoCache.get(url)
companion object {
// waits 3 minutes to reconnect once things fail
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient =
if (url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")) {
HttpClientManager.getHttpClient(false)
} else {
HttpClientManager.getHttpClient()
}
val brief = RelayBriefInfoCache.get(url)
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
private var usingCompression: Boolean = false
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
private var lastConnectTentative: Long = 0L
var spamCounter = 0
var errorCounter = 0
var pingInMs: Long? = null
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
var lastConnectTentative: Long = 0L
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
val authResponse = mutableMapOf<HexKey, Boolean>()
val sendWhenReady = mutableListOf<EventInterface>()
private val authResponse = mutableMapOf<HexKey, Boolean>()
private val sendWhenReady = mutableListOf<EventInterface>()
fun register(listener: Listener) {
listeners = listeners.plus(listener)
@@ -116,7 +103,7 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingBlock.get()}")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -125,26 +112,29 @@ class Relay(
return
}
checkNotInMainThread()
if (socket != null) return
lastConnectTentative = TimeUtils.now()
try {
checkNotInMainThread()
if (socket != null) {
connectingBlock.set(false)
return
}
lastConnectTentative = TimeUtils.now()
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
socket = HttpClientManager.getHttpClientForUrl(url).newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
if (e is CancellationException) throw e
errorCounter++
RelayStats.newError(url, e.message)
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
} finally {
connectingBlock.set(false)
@@ -184,7 +174,7 @@ class Relay(
) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
RelayStats.addBytesReceived(url, text.bytesUsedInMemory())
try {
processNewRelayMessage(text)
@@ -236,21 +226,25 @@ class Relay(
) {
checkNotInMainThread()
errorCounter++
socket?.cancel() // 1000, "Normal close"
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
RelayStats.newError(url, response?.message ?: t.message)
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response ${t.message} $socket")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
}
@@ -260,8 +254,9 @@ class Relay(
) {
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
RelayStats.setPing(url, pingInMs)
}
fun markConnectionAsClosed() {
@@ -303,6 +298,8 @@ class Relay(
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
RelayStats.newNotice(url, message)
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" ->
@@ -333,7 +330,9 @@ class Relay(
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
else ->
else -> {
RelayStats.newError(url, "Unsupported message: $newMessage")
listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
@@ -342,6 +341,7 @@ class Relay(
Error("Unknown type $type on channel. Msg was $newMessage"),
)
}
}
}
}
@@ -386,10 +386,8 @@ class Relay(
it.filter.toJson(url)
}
// Log.d("Relay", "onFilterSent $url $requestId $request")
writeToSocket(request)
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
resetEOSEStatuses()
}
}
@@ -454,30 +452,9 @@ class Relay(
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
sendAuth(signedEvent)
} else {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
renewFilters()
}
}
sendEvent(signedEvent)
}
}
@@ -485,18 +462,12 @@ class Relay(
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
sendAuth(signedEvent)
} else {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
} else {
synchronized(sendWhenReady) {
sendWhenReady.add(signedEvent)
@@ -505,10 +476,7 @@ class Relay(
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
// Sends everything.
renewFilters()
@@ -518,19 +486,49 @@ class Relay(
}
}
fun close(subscriptionId: String) {
checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]"""
// Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg)
private fun sendAuth(signedEvent: RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
writeToSocket("""["AUTH",${signedEvent.toJson()}]""")
}
fun isSameRelayConfig(other: Relay): Boolean {
private fun sendEvent(signedEvent: EventInterface) {
if (isConnected()) {
if (isReady) {
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
}
} else {
// sends all filters after connection is successful.
connectAndRun {
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
// Sends everything.
renewFilters()
}
}
}
private fun writeToSocket(str: String) {
socket?.let {
checkNotInMainThread()
it.send(str)
RelayStats.addBytesSent(url, str.bytesUsedInMemory())
if (BuildConfig.DEBUG) {
Log.d("Relay", "Relay send $url $str")
}
}
}
fun close(subscriptionId: String) {
writeToSocket("""["CLOSE","$subscriptionId"]""")
}
fun isSameRelayConfig(other: RelaySetupInfo): Boolean {
return url == other.url &&
write == other.write &&
read == other.read &&
activeTypes == other.activeTypes
activeTypes == other.feedTypes
}
enum class StateType {

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@@ -91,6 +92,7 @@ object RelayPool : Relay.Listener {
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
timeout: Long = 60000,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
addRelay(relay)
@@ -103,7 +105,7 @@ object RelayPool : Relay.Listener {
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(60000) // waits for a reply
delay(timeout) // waits for a reply
relay.disconnect()
removeRelay(relay)
@@ -147,7 +149,7 @@ object RelayPool : Relay.Listener {
}
fun sendToSelectedRelays(
list: List<Relay>,
list: List<RelaySetupInfo>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } }
@@ -197,12 +199,6 @@ object RelayPool : Relay.Listener {
afterEOSE: Boolean,
)
fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
)
fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
@@ -241,7 +237,6 @@ object RelayPool : Relay.Listener {
subscriptionId: String,
error: Error,
) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
updateStatus()
}

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relays
import androidx.collection.LruCache
import com.vitorpamplona.quartz.utils.TimeUtils
object RelayStats {
private val innerCache = mutableMapOf<String, RelayStat>()
fun get(url: String): RelayStat {
return innerCache.getOrPut(url) { RelayStat() }
}
fun addBytesReceived(
url: String,
bytesUsedInMemory: Int,
) {
get(url).addBytesReceived(bytesUsedInMemory)
}
fun addBytesSent(
url: String,
bytesUsedInMemory: Int,
) {
get(url).addBytesSent(bytesUsedInMemory)
}
fun newError(
url: String,
error: String?,
) {
get(url).newError(error)
}
fun newNotice(
url: String,
notice: String?,
) {
get(url).newNotice(notice)
}
fun setPing(
url: String,
pingInMs: Long,
) {
get(url).pingInMs = pingInMs
}
fun newSpam(
url: String,
explanation: String,
) {
get(url).newSpam(explanation)
}
}
class RelayStat(
var receivedBytes: Long = 0L,
var sentBytes: Long = 0L,
var spamCounter: Long = 0L,
var errorCounter: Long = 0L,
var pingInMs: Long = 0L,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
fun newNotice(notice: String?) {
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.NOTICE,
message = notice ?: "No error message provided",
)
messages.put(debugMessage, debugMessage)
}
fun newError(error: String?) {
errorCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.ERROR,
message = error ?: "No error message provided",
)
messages.put(debugMessage, debugMessage)
}
fun addBytesReceived(bytesUsedInMemory: Int) {
receivedBytes += bytesUsedInMemory
}
fun addBytesSent(bytesUsedInMemory: Int) {
sentBytes += bytesUsedInMemory
}
fun newSpam(spamDescriptor: String) {
spamCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
messages.put(debugMessage, debugMessage)
}
}
class RelayDebugMessage(
val type: RelayDebugMessageType,
val message: String,
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
}

View File

@@ -45,6 +45,10 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(act.isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
}

View File

@@ -304,11 +304,13 @@ fun EditPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
@@ -323,7 +325,7 @@ fun EditPostView(
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
}
}
}

View File

@@ -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

View File

@@ -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.
*

View File

@@ -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) {

View File

@@ -251,7 +251,9 @@ fun ImageVideoPost(
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
mimeType = postViewModel.mediaType,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}

View File

@@ -425,11 +425,13 @@ fun NewPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
@@ -444,7 +446,7 @@ fun NewPostView(
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
}
}
}
@@ -1736,7 +1738,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel)
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
}
}

View File

@@ -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) {

View File

@@ -63,15 +63,16 @@ fun NotifyRequestDialog(
val background = remember { mutableStateOf(defaultBackground) }
TranslatableRichTextViewer(
textContent,
content = textContent,
canPreview = true,
quotesLeft = 1,
Modifier.fillMaxWidth(),
EmptyTagList,
background,
textContent,
accountViewModel,
nav,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = background,
id = textContent,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
)
},
confirmButton = {

View File

@@ -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,
) {

View File

@@ -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
}
}
}

View File

@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.RelayStat
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -236,7 +237,7 @@ fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultRelays.forEach { postViewModel.addRelay(it) }
postViewModel.addAll(Constants.defaultRelays)
postViewModel.loadRelayDocuments()
},
) {
@@ -249,7 +250,7 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it)) }
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {

View File

@@ -22,14 +22,25 @@ package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayStat
@Immutable
data class BasicRelaySetupInfo(
val url: String,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@Immutable
data class Kind3BasicRelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)

View File

@@ -24,7 +24,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -78,20 +79,11 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
val relayList = getRelayList() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
}

View File

@@ -28,6 +28,14 @@ fun countToHumanReadableBytes(counter: Int) =
else -> "$counter"
}
fun countToHumanReadableBytes(counter: Long) =
when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(
counter: Int,
str: String,
@@ -37,3 +45,13 @@ fun countToHumanReadable(
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
else -> "$counter $str"
}
fun countToHumanReadable(
counter: Long,
str: String,
) = when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
else -> "$counter $str"
}

View File

@@ -68,11 +68,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayStat
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -95,7 +95,7 @@ import kotlinx.coroutines.launch
@Composable
fun Kind3RelayListView(
feedState: List<RelaySetupInfo>,
feedState: List<Kind3BasicRelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
@@ -110,7 +110,7 @@ fun Kind3RelayListView(
}
fun LazyListScope.renderKind3Items(
feedState: List<RelaySetupInfo>,
feedState: List<Kind3BasicRelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
@@ -147,14 +147,17 @@ fun ServerConfigPreview() {
ClickableRelayItem(
loadProfilePicture = true,
item =
RelaySetupInfo(
Kind3BasicRelaySetupInfo(
url = "nostr.mom",
read = true,
write = true,
errorCount = 23,
downloadCountInBytes = 10000,
uploadCountInBytes = 10000000,
spamCount = 10,
relayStat =
RelayStat(
errorCounter = 23,
receivedBytes = 10000,
sentBytes = 10000000,
spamCounter = 10,
),
feedTypes = Constants.activeTypesGlobalChats,
paidRelay = true,
),
@@ -172,15 +175,15 @@ fun ServerConfigPreview() {
@Composable
fun LoadRelayInfo(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
item: Kind3BasicRelaySetupInfo,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -263,16 +266,16 @@ fun LoadRelayInfo(
@Composable
fun ClickableRelayItem(
item: RelaySetupInfo,
item: Kind3BasicRelaySetupInfo,
loadProfilePicture: Boolean,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
@@ -333,9 +336,9 @@ fun ClickableRelayItem(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun StatusRow(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
item: Kind3BasicRelaySetupInfo,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
val scope = rememberCoroutineScope()
@@ -370,7 +373,7 @@ private fun StatusRow(
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.receivedBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -406,7 +409,7 @@ private fun StatusRow(
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.sentBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -432,7 +435,7 @@ private fun StatusRow(
},
),
tint =
if (item.errorCount > 0) {
if (item.relayStat.errorCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -440,7 +443,7 @@ private fun StatusRow(
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
text = countToHumanReadable(item.relayStat.errorCounter, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -466,7 +469,7 @@ private fun StatusRow(
},
),
tint =
if (item.spamCount > 0) {
if (item.relayStat.spamCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -474,7 +477,7 @@ private fun StatusRow(
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
text = countToHumanReadable(item.relayStat.spamCounter, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -485,11 +488,11 @@ private fun StatusRow(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ActiveToggles(
item: RelaySetupInfo,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
item: Kind3BasicRelaySetupInfo,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -639,9 +642,9 @@ private fun ActiveToggles(
@Composable
private fun FirstLine(
item: RelaySetupInfo,
item: Kind3BasicRelaySetupInfo,
onClick: () -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
@@ -680,7 +683,7 @@ private fun FirstLine(
@Composable
fun Kind3RelayEditBox(
relayToAdd: String,
onNewRelay: (RelaySetupInfo) -> Unit,
onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit,
) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
@@ -733,13 +736,13 @@ fun Kind3RelayEditBox(
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
val addedWSS = RelayUrlFormatter.normalize(url)
onNewRelay(
RelaySetupInfo(
addedWSS,
read,
write,
feedTypes = FeedType.values().toSet(),
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(url),
read = read,
write = write,
feedTypes = FeedType.entries.toSet(),
relayStat = RelayStat(),
),
)
url = ""

View File

@@ -27,7 +27,8 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,7 +39,7 @@ import kotlinx.coroutines.launch
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
var hasModified = false
@@ -52,7 +53,16 @@ class Kind3RelayListViewModel : ViewModel() {
fun create() {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
account.saveKind3RelayList(relays.value)
account.saveKind3RelayList(
relays.value.map {
RelaySetupInfo(
it.url,
it.read,
it.write,
it.feedTypes,
)
},
)
clear()
}
}
@@ -80,7 +90,6 @@ class Kind3RelayListViewModel : ViewModel() {
if (relayFile != null) {
relayFile
.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes =
account.localRelays
.filter { localRelay -> localRelay.url == it.key }
@@ -92,54 +101,55 @@ class Kind3RelayListViewModel : ViewModel() {
?.feedTypes
?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(
it.key,
it.value.read,
it.value.write,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
localInfoFeedTypes,
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.key),
read = it.value.read,
write = it.value.write,
feedTypes = localInfoFeedTypes,
relayStat = RelayStats.get(it.key),
)
}
.distinctBy { it.url }
.sortedBy { it.downloadCountInBytes }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
} else {
account.localRelays
.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(
it.url,
it.read,
it.write,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
it.feedTypes,
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = it.read,
write = it.write,
feedTypes = it.feedTypes,
relayStat = RelayStats.get(it.url),
)
}
.distinctBy { it.url }
.sortedBy { it.downloadCountInBytes }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
}
fun addRelay(relay: RelaySetupInfo) {
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
hasModified = true
_relays.update {
defaultRelays.map {
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = it.read,
write = it.write,
feedTypes = it.feedTypes,
relayStat = RelayStats.get(it.url),
)
}
.distinctBy { it.url }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
fun addRelay(relay: Kind3BasicRelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
@@ -147,7 +157,7 @@ class Kind3RelayListViewModel : ViewModel() {
hasModified = true
}
fun deleteRelay(relay: RelaySetupInfo) {
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
hasModified = true
}
@@ -157,48 +167,48 @@ class Kind3RelayListViewModel : ViewModel() {
hasModified = true
}
fun toggleDownload(relay: RelaySetupInfo) {
fun toggleDownload(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(read = !relay.read)) }
hasModified = true
}
fun toggleUpload(relay: RelaySetupInfo) {
fun toggleUpload(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(write = !relay.write)) }
hasModified = true
}
fun toggleFollows(relay: RelaySetupInfo) {
fun toggleFollows(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleMessages(relay: RelaySetupInfo) {
fun toggleMessages(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePublicChats(relay: RelaySetupInfo) {
fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleGlobal(relay: RelaySetupInfo) {
fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleSearch(relay: RelaySetupInfo) {
fun toggleSearch(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePaidRelay(
relay: RelaySetupInfo,
relay: Kind3BasicRelaySetupInfo,
paid: Boolean,
) {
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }

View File

@@ -24,7 +24,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -105,40 +106,22 @@ class Nip65RelayListViewModel : ViewModel() {
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
_notificationRelays.update {
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
}

View File

@@ -31,15 +31,18 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -48,18 +51,24 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.events.EmptyTagList
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -70,6 +79,11 @@ fun RelayInformationDialog(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val messages =
remember(relayBriefInfo) {
RelayStats.get(url = relayBriefInfo.url).messages.snapshot().values.sortedByDescending { it.time }.toImmutableList()
}
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
@@ -84,162 +98,204 @@ fun RelayInformationDialog(
),
) {
Surface {
val scrollState = rememberScrollState()
val color = mutableStateOf(Color.Transparent)
val context = LocalContext.current
Column(
modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState),
LazyColumn(
modifier =
Modifier
.padding(10.dp)
.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = StdPadding.fillMaxWidth(),
) {
Column {
RenderRelayIcon(
relayBriefInfo.displayUrl,
relayInfo.icon ?: relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = DoubleHorzSpacer)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row { Title(relayInfo.name?.trim() ?: "") }
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = StdPadding.fillMaxWidth(),
) {
Column {
RenderRelayIcon(
relayBriefInfo.displayUrl,
relayInfo.icon ?: relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Section(stringResource(R.string.owner))
Spacer(modifier = DoubleHorzSpacer)
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row { Title(relayInfo.name?.trim() ?: "") }
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
}
}
}
item {
Section(stringResource(R.string.owner))
Section(stringResource(R.string.software))
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
}
}
}
item {
Section(stringResource(R.string.software))
DisplaySoftwareInformation(relayInfo)
DisplaySoftwareInformation(relayInfo)
Section(stringResource(R.string.version))
Section(stringResource(R.string.version))
SectionContent(relayInfo.version ?: "")
SectionContent(relayInfo.version ?: "")
}
item {
Section(stringResource(R.string.contact))
Section(stringResource(R.string.contact))
Box(modifier = Modifier.padding(start = 10.dp)) {
relayInfo.contact?.let {
if (it.startsWith("https:")) {
ClickableUrl(urlText = it, url = it)
} else if (it.startsWith("mailto:") || it.contains('@')) {
ClickableEmail(it)
} else {
SectionContent(it)
}
}
}
}
item {
Section(stringResource(R.string.supports))
Box(modifier = Modifier.padding(start = 10.dp)) {
relayInfo.contact?.let {
if (it.startsWith("https:")) {
ClickableUrl(urlText = it, url = it)
} else if (it.startsWith("mailto:") || it.contains('@')) {
ClickableEmail(it)
} else {
SectionContent(it)
DisplaySupportedNips(relayInfo)
}
item {
relayInfo.fees?.admission?.let {
if (it.isNotEmpty()) {
Section(stringResource(R.string.admission_fees))
it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") }
}
}
relayInfo.payments_url?.let {
Section(stringResource(R.string.payments_url))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = it,
url = it,
)
}
}
}
item {
relayInfo.limitation?.let {
Section(stringResource(R.string.limitations))
val authRequiredText =
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequiredText =
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val restrictedWritesText =
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
Column {
SectionContent(
"${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
)
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
SectionContent(
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
SectionContent(
"${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}",
)
SectionContent(
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
}
}
}
item {
relayInfo.relay_countries?.let {
Section(stringResource(R.string.countries))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
}
item {
relayInfo.language_tags?.let {
Section(stringResource(R.string.languages))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
}
item {
relayInfo.tags?.let {
Section(stringResource(R.string.tags))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
}
item {
relayInfo.posting_policy?.let {
Section(stringResource(R.string.posting_policy))
Box(Modifier.padding(10.dp)) {
ClickableUrl(
it,
it,
)
}
}
}
Section(stringResource(R.string.supports))
DisplaySupportedNips(relayInfo)
relayInfo.fees?.admission?.let {
if (it.isNotEmpty()) {
Section(stringResource(R.string.admission_fees))
it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") }
}
item {
Section(stringResource(R.string.relay_error_messages))
}
relayInfo.payments_url?.let {
Section(stringResource(R.string.payments_url))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = it,
url = it,
items(messages) { msg ->
Row {
TranslatableRichTextViewer(
content =
remember {
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
},
canPreview = false,
quotesLeft = 0,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = color,
id = msg.hashCode().toString(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
relayInfo.limitation?.let {
Section(stringResource(R.string.limitations))
val authRequiredText =
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequiredText =
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val restrictedWritesText =
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
Column {
SectionContent(
"${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
)
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
SectionContent(
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
SectionContent(
"${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}",
)
SectionContent(
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
}
}
relayInfo.relay_countries?.let {
Section(stringResource(R.string.countries))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.language_tags?.let {
Section(stringResource(R.string.languages))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.tags?.let {
Section(stringResource(R.string.tags))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.posting_policy?.let {
Section(stringResource(R.string.posting_policy))
Box(Modifier.padding(10.dp)) {
ClickableUrl(
it,
it,
)
}
Spacer(modifier = StdVertSpacer)
}
}
}

View File

@@ -75,7 +75,7 @@ fun RelayStatusRow(
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.receivedBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -101,7 +101,7 @@ fun RelayStatusRow(
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.sentBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -124,7 +124,7 @@ fun RelayStatusRow(
},
),
tint =
if (item.errorCount > 0) {
if (item.relayStat.errorCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -132,7 +132,7 @@ fun RelayStatusRow(
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
text = countToHumanReadable(item.relayStat.errorCounter, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -163,7 +163,7 @@ fun RelayStatusRow(
},
),
tint =
if (item.spamCount > 0) {
if (item.relayStat.spamCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -171,7 +171,7 @@ fun RelayStatusRow(
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
text = countToHumanReadable(item.relayStat.spamCounter, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relays.RelayStat
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -65,8 +66,7 @@ fun RelayUrlEditField(onNewRelay: (BasicRelaySetupInfo) -> Unit) {
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
val addedWSS = RelayUrlFormatter.normalize(url)
onNewRelay(BasicRelaySetupInfo(addedWSS))
onNewRelay(BasicRelaySetupInfo(RelayUrlFormatter.normalize(url), RelayStat()))
url = ""
}
},

View File

@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun ClickableUrl(
@@ -39,6 +40,8 @@ fun ClickableUrl(
ClickableText(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onClick = {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"

View File

@@ -64,6 +64,7 @@ fun ExpandableRichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -99,6 +100,7 @@ fun ExpandableRichTextViewer(
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
callbackUri,
accountViewModel,
nav,
)

View File

@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
fun LoadUrlPreview(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value }
@@ -61,7 +62,7 @@ fun LoadUrlPreview(
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
RenderLoaded(state, url, accountViewModel)
RenderLoaded(state, url, callbackUri, accountViewModel)
}
else -> {
ClickableUrl(urlText, url)
@@ -75,21 +76,24 @@ fun LoadUrlPreview(
fun RenderLoaded(
state: UrlPreviewState.Loaded,
url: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
if (state.previewInfo.mimeType.type == "image") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlImage(url),
content = MediaUrlImage(url, uri = callbackUri),
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
} else if (state.previewInfo.mimeType.type == "video") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlVideo(url),
content = MediaUrlVideo(url, uri = callbackUri),
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}

View File

@@ -121,14 +121,15 @@ fun RichTextViewer(
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
}
}
}
@@ -267,7 +268,7 @@ fun RenderRegularPreview3() {
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, null, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
@@ -291,16 +292,18 @@ private fun RenderRegular(
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
RenderRegular(content, tags) { word, state ->
RenderRegular(content, tags, callbackUri) { word, state ->
if (canPreview) {
RenderWordWithPreview(
word,
state,
backgroundColor,
quotesLeft,
callbackUri,
accountViewModel,
nav,
)
@@ -321,9 +324,10 @@ private fun RenderRegular(
fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
) {
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) }
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
@@ -412,12 +416,13 @@ private fun RenderWordWithPreview(
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
quotesLeft: Int,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
@@ -441,7 +446,7 @@ private fun ZoomableContentView(
) {
state.imagesForPager[word]?.let {
Box(modifier = HalfVertPadding) {
ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel)
ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel)
}
}
}

View File

@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.content.Context
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -36,7 +38,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
@@ -44,17 +45,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -72,13 +78,10 @@ import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -89,10 +92,15 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
@@ -100,9 +108,12 @@ import com.vitorpamplona.amethyst.ui.note.MuteIcon
import com.vitorpamplona.amethyst.ui.note.MutedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size110dp
import com.vitorpamplona.amethyst.ui.theme.Size165dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@@ -123,10 +134,12 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@Composable
fun LoadThumbAndThenVideoView(
videoUri: String,
mimeType: String?,
title: String? = null,
thumbUri: String,
authorName: String? = null,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null,
@@ -139,11 +152,12 @@ fun LoadThumbAndThenVideoView(
context,
thumbUri,
onReady = {
if (it != null) {
loadingFinished = Pair(true, it)
} else {
loadingFinished = Pair(true, null)
}
loadingFinished =
if (it != null) {
Pair(true, it)
} else {
Pair(true, null)
}
},
onError = { loadingFinished = Pair(true, null) },
)
@@ -153,9 +167,11 @@ fun LoadThumbAndThenVideoView(
if (loadingFinished.second != null) {
VideoView(
videoUri = videoUri,
mimeType = mimeType,
title = title,
thumb = VideoThumb(loadingFinished.second),
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
@@ -165,9 +181,11 @@ fun LoadThumbAndThenVideoView(
} else {
VideoView(
videoUri = videoUri,
mimeType = mimeType,
title = title,
thumb = null,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
@@ -181,10 +199,11 @@ fun LoadThumbAndThenVideoView(
@Composable
fun VideoView(
videoUri: String,
mimeType: String?,
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
@@ -208,7 +227,7 @@ fun VideoView(
if (blurhash == null) {
val ratio = aspectRatio(dimensions)
val modifier =
if (ratio != null && roundedCorner && automaticallyStartPlayback.value) {
if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio)
} else {
Modifier
@@ -220,40 +239,50 @@ fun VideoView(
} else {
VideoViewInner(
videoUri = videoUri,
mimeType = mimeType,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
accountViewModel = accountViewModel,
)
}
}
} else {
val ratio = aspectRatio(dimensions)
val modifier =
if (ratio != null && roundedCorner) {
if (ratio != null) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
Box(modifier, contentAlignment = Alignment.Center) {
val image =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
// Always displays Blurharh to avoid size flickering
DisplayBlurHash(
blurhash,
null,
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
if (ratio != null) image.aspectRatio(ratio) else modifier,
)
if (!automaticallyStartPlayback.value) {
DisplayBlurHash(
blurhash,
null,
ContentScale.Crop,
MaterialTheme.colorScheme.imageModifier,
)
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { automaticallyStartPlayback.value = true },
@@ -263,20 +292,20 @@ fun VideoView(
} else {
VideoViewInner(
videoUri = videoUri,
mimeType = mimeType,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
accountViewModel = accountViewModel,
)
}
}
@@ -287,20 +316,20 @@ fun VideoView(
@OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoViewInner(
videoUri: String,
mimeType: String?,
defaultToStart: Boolean = false,
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
@@ -311,12 +340,13 @@ fun VideoViewInner(
nostrUriCallback = nostrUriCallback,
) { controller, keepPlaying ->
RenderVideoPlayer(
videoUri = videoUri,
mimeType = mimeType,
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
dimensions = dimensions,
blurhash = blurhash,
topPaddingForControllers = topPaddingForControllers,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
@@ -324,6 +354,7 @@ fun VideoViewInner(
modifier = modifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
accountViewModel = accountViewModel,
)
}
}
@@ -341,18 +372,18 @@ data class MediaItemData(
)
class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) {
override suspend fun compute(data: MediaItemData): MediaItem? {
override suspend fun compute(key: MediaItemData): MediaItem {
return MediaItem.Builder()
.setMediaId(data.videoUri)
.setUri(data.videoUri)
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(data.authorName?.ifBlank { null })
.setTitle(data.title?.ifBlank { null } ?: data.videoUri)
.setArtist(key.authorName?.ifBlank { null })
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setArtworkUri(
try {
if (data.artworkUri != null) {
Uri.parse(data.artworkUri)
if (key.artworkUri != null) {
Uri.parse(key.artworkUri)
} else {
null
}
@@ -484,14 +515,20 @@ fun GetVideoController(
}
onDispose {
GlobalScope.launch(Dispatchers.Main) {
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.let {
if (!keepPlaying.value) {
// Makes sure the variable is cleared before the task is launched
// to avoid the ON_RELEASE running before ON_PAUSE's coroutine
val toRelease = controller.value
controller.value = null
toRelease?.let {
it.pause()
GlobalScope.launch(Dispatchers.Main) {
// Stops and releases the media.
it.stop()
it.release()
Log.d("PlaybackService", "Releasing Video $videoUri ")
controller.value = null
}
}
}
@@ -542,14 +579,21 @@ fun GetVideoController(
}
}
if (event == Lifecycle.Event.ON_PAUSE) {
GlobalScope.launch(Dispatchers.Main) {
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.let {
if (!keepPlaying.value) {
// Stops and releases the media.
// Makes sure the variable is cleared before the task is launched
// to avoid the ON_RELEASE running before ON_PAUSE's coroutine
val toRelease = controller.value
controller.value = null
toRelease?.let {
it.pause()
scope.launch(Dispatchers.Main) {
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
it.stop()
it.release()
controller.value = null
Log.d("PlaybackService", "Released Video from Pause $videoUri ")
}
}
}
@@ -637,12 +681,13 @@ data class VideoThumb(
@Composable
@OptIn(UnstableApi::class)
private fun RenderVideoPlayer(
videoUri: String,
mimeType: String?,
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
dimensions: String? = null,
blurhash: String? = null,
topPaddingForControllers: Dp = Dp.Unspecified,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>,
@@ -650,25 +695,17 @@ private fun RenderVideoPlayer(
modifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel,
) {
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
val controllerVisible = remember(controller) { mutableStateOf(false) }
val videoPlaybackHeight = remember { mutableStateOf<Dp>(Dp.Unspecified) }
val localDensity = LocalDensity.current
BoxWithConstraints(
modifier =
Modifier.onGloballyPositioned { coordinates ->
videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() }
},
) {
Box {
val borders = MaterialTheme.colorScheme.imageModifier
val myModifier =
remember {
remember(controller) {
if (roundedCorner) {
modifier.then(
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
@@ -678,17 +715,6 @@ private fun RenderVideoPlayer(
}
}
val ratio = remember { aspectRatio(dimensions) }
if (ratio != null) {
DisplayBlurHash(
blurhash,
null,
ContentScale.Crop,
myModifier.aspectRatio(ratio),
)
}
AndroidView(
modifier = myModifier,
factory = { context: Context ->
@@ -705,7 +731,7 @@ private fun RenderVideoPlayer(
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
if (maxHeight.isFinite) {
if (isFiniteHeight) {
AspectRatioFrameLayout.RESIZE_MODE_FIT
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
@@ -730,26 +756,10 @@ private fun RenderVideoPlayer(
val startingMuteState = remember(controller) { controller.volume < 0.001 }
val topPadding =
remember {
derivedStateOf {
if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) {
val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp
if (space > topPaddingForControllers) {
Size0dp
} else {
topPaddingForControllers - space
}
} else {
Size0dp
}
}
}
MuteButton(
controllerVisible,
startingMuteState,
topPadding,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
@@ -767,8 +777,7 @@ private fun RenderVideoPlayer(
KeepPlayingButton(
keepPlaying,
controllerVisible,
topPadding,
Modifier.align(Alignment.TopEnd),
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other
// one.
@@ -786,6 +795,14 @@ private fun RenderVideoPlayer(
keepPlaying.value = newKeepPlaying
}
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle)
}
}
}
@@ -804,7 +821,7 @@ fun Waveform(
controller: MediaController,
modifier: Modifier,
) {
val waveformProgress = remember { mutableStateOf(0F) }
val waveformProgress = remember { mutableFloatStateOf(0F) }
DrawWaveform(waveform, waveformProgress, modifier)
@@ -818,7 +835,7 @@ fun Waveform(
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.value += 1
restartFlow.intValue += 1
}
}
}
@@ -827,28 +844,28 @@ fun Waveform(
onDispose { controller.removeListener(listener) }
}
LaunchedEffect(key1 = restartFlow.value) {
pollCurrentDuration(controller).collect { value -> waveformProgress.value = value }
LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentDuration(controller).collect { value -> waveformProgress.floatValue = value }
}
}
@Composable
fun DrawWaveform(
waveform: ImmutableList<Int>,
waveformProgress: MutableState<Float>,
waveformProgress: MutableFloatState,
modifier: Modifier,
) {
AudioWaveformReadOnly(
modifier = modifier.padding(start = 10.dp, end = 10.dp),
amplitudes = waveform,
progress = waveformProgress.value,
progress = waveformProgress.floatValue,
progressBrush =
Brush.infiniteLinearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
animation = tween(durationMillis = 6000, easing = LinearEasing),
width = 128F,
),
onProgressChange = { waveformProgress.value = it },
onProgressChange = { waveformProgress.floatValue = it },
)
}
@@ -953,7 +970,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
private fun MuteButton(
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
topPadding: State<Dp>,
modifier: Modifier,
toggle: (Boolean) -> Unit,
) {
val holdOn =
@@ -974,7 +991,7 @@ private fun MuteButton(
AnimatedVisibility(
visible = holdOn.value || controllerVisible.value,
modifier = Modifier.padding(top = topPadding.value),
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
@@ -1007,15 +1024,14 @@ private fun MuteButton(
private fun KeepPlayingButton(
keepPlayingStart: MutableState<Boolean>,
controllerVisible: MutableState<Boolean>,
topPadding: State<Dp>,
alignment: Modifier,
modifier: Modifier,
toggle: (Boolean) -> Unit,
) {
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
AnimatedVisibility(
visible = controllerVisible.value,
modifier = alignment.padding(top = topPadding.value),
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
@@ -1043,3 +1059,125 @@ private fun KeepPlayingButton(
}
}
}
@Composable
fun AnimatedSaveButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
onSaveClick: (localContext: Context) -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
SaveButton(onSaveClick)
}
}
@Composable
fun AnimatedShareButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
ShareButton(innerAction)
}
}
@Composable
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
val popupExpanded = remember { mutableStateOf(false) }
IconButton(
onClick = {
popupExpanded.value = true
},
modifier = Size50Modifier,
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringResource(R.string.share_or_save),
)
innerAction(popupExpanded) { popupExpanded.value = false }
}
}
}
@kotlin.OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
val localContext = LocalContext.current
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
onSaveClick(localContext)
}
}
IconButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
onSaveClick(localContext)
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
modifier = Size50Modifier,
) {
Icon(
imageVector = Icons.Default.Download,
modifier = Size20Modifier,
contentDescription = stringResource(R.string.save_to_gallery),
)
}
}
}
private fun saveImage(
videoUri: String?,
mimeType: String?,
localContext: Context,
accountViewModel: AccountViewModel,
) {
ImageSaver.saveImage(
videoUri = videoUri,
mimeType = mimeType,
localContext = localContext,
onSuccess = {
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
},
onError = {
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
},
)
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -57,6 +57,7 @@ class MarkdownMediaRenderer(
val canPreview: Boolean,
val quotesLeft: Int,
val backgroundColor: MutableState<Color>,
val callbackUri: String? = null,
val accountViewModel: AccountViewModel,
val nav: (String) -> Unit,
) : MediaRenderer {
@@ -94,6 +95,7 @@ class MarkdownMediaRenderer(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
@@ -107,7 +109,7 @@ class MarkdownMediaRenderer(
uri: String,
richTextStringBuilder: RichTextString.Builder,
) {
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText)
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
if (canPreview) {
if (content != null) {
@@ -115,6 +117,7 @@ class MarkdownMediaRenderer(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
@@ -123,7 +126,7 @@ class MarkdownMediaRenderer(
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
} else {
renderInlineFullWidth(richTextStringBuilder) {
LoadUrlPreview(uri, title ?: uri, accountViewModel)
LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel)
}
}
}

View File

@@ -44,6 +44,7 @@ fun RenderContentAsMarkdown(
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -70,13 +71,14 @@ fun RenderContentAsMarkdown(
val renderer =
remember(content) {
MarkdownMediaRenderer(
content.take(100),
tags,
canPreview,
quotesLeft,
backgroundColor,
accountViewModel,
nav,
startOfText = content.take(100),
tags = tags,
canPreview = canPreview,
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}

View File

@@ -48,12 +48,11 @@ open class DiscoverNIP89FeedFilter(
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val filterParams = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is AppDefinitionEvent && noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent)
acceptDVM(it)
}
return sort(notes)
@@ -72,12 +71,26 @@ open class DiscoverNIP89FeedFilter(
)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
fun acceptDVM(note: Note): Boolean {
val noteEvent = note.event
return if (noteEvent is AppDefinitionEvent) {
acceptDVM(noteEvent)
} else {
false
}
}
fun acceptDVM(noteEvent: AppDefinitionEvent): Boolean {
val filterParams = buildFilterParams(account)
return noteEvent.appMetaData()?.subscription != true &&
filterParams.match(noteEvent) &&
noteEvent.includeKind("5300") &&
noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is AppDefinitionEvent && noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent)
acceptDVM(it)
}
}

View File

@@ -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 ||

View File

@@ -23,10 +23,13 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -65,7 +68,12 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
): Boolean {
val noteEvent = it.event
return ((noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isImageOrVideo()) || (noteEvent is FileStorageHeaderEvent && noteEvent.isImageOrVideo())) &&
return (
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
account.isAcceptable(it)
}

View File

@@ -23,16 +23,15 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
@@ -46,16 +45,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.bottomIconModifier
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
import kotlinx.collections.immutable.persistentListOf
val bottomNavigationItems =
@@ -190,7 +186,7 @@ private fun NotifiableIcon(
fun AddNotifIconIfNeeded(
route: Route,
accountViewModel: AccountViewModel,
modifier: Modifier,
modifier: Modifier = Modifier,
) {
val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return
val hasNewItems by flow.collectAsStateWithLifecycle()
@@ -201,18 +197,8 @@ fun AddNotifIconIfNeeded(
@Composable
private fun NotificationDotIcon(modifier: Modifier) {
Box(modifier.size(Size10dp)) {
Box(
modifier = MaterialTheme.colorScheme.bottomIconModifier,
contentAlignment = Alignment.TopEnd,
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
// modifier = Modifier.wrapContentHeight().align(Alignment.TopEnd),
)
}
}
val color = MaterialTheme.colorScheme.primary
Canvas(modifier = Size10Modifier.then(modifier), onDraw = {
drawCircle(color = color)
})
}

View File

@@ -396,9 +396,12 @@ fun AppNavigation(
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, it)) {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
actionableNextPage = null
}

View File

@@ -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,

View File

@@ -759,6 +759,9 @@ fun RenderContentDVMThumb(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
// downloads user metadata to pre-load the NIP-65 relays.
val user = baseNote.author?.live()?.metadata?.observeAsState()
val card = observeAppDefinition(appDefinitionNote = baseNote)
LeftPictureLayout(
@@ -775,7 +778,16 @@ fun RenderContentDVMThumb(
.clip(QuoteBorder),
)
}
} ?: run { DisplayAuthorBanner(baseNote) }
} ?: run {
user?.value?.user?.let {
BannerImage(
it,
Modifier
.fillMaxSize()
.clip(QuoteBorder),
)
}
}
},
onTitleRow = {
Text(

View File

@@ -29,10 +29,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -40,32 +40,28 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -79,14 +75,16 @@ import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox
import com.vitorpamplona.amethyst.ui.theme.chatAuthorImage
import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -132,7 +130,6 @@ fun ChatroomMessageCompose(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NormalChatNote(
note: Note,
@@ -145,6 +142,17 @@ fun NormalChatNote(
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
val isLoggedInUser =
remember(note.author) {
accountViewModel.isLoggedUser(note.author)
}
if (routeForLastRead != null) {
LaunchedEffect(key1 = routeForLastRead) {
accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt())
}
}
val drawAuthorInfo by
remember(note) {
derivedStateOf {
@@ -162,13 +170,104 @@ fun NormalChatNote(
}
}
ChatBubbleLayout(
isLoggedInUser = isLoggedInUser,
innerQuote = innerQuote,
isSimplified = accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED,
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo,
parentBackgroundColor = parentBackgroundColor,
onClick = {
if (note.event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
true
} else {
false
}
},
onAuthorClick = {
note.author?.let {
nav("User/${it.pubkeyHex}")
}
},
actionMenu = { popupExpanded, onDismiss ->
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = onDismiss,
onWantsToEditDraft = { onWantsToEditDraft(note) },
accountViewModel = accountViewModel,
nav = nav,
)
},
drawAuthorLine = {
DrawAuthorInfo(
note,
accountViewModel,
nav,
)
},
detailRow = {
if (note.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(note)
ChatTimeAgo(note)
RelayBadgesHorizontal(note, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSizeModifier = Size15Modifier,
) {
onWantsToReply(note)
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
}
},
) { backgroundBubbleColor ->
MessageBubbleLines(
note,
innerQuote,
backgroundBubbleColor,
onWantsToReply,
onWantsToEditDraft,
canPreview,
accountViewModel,
nav,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatBubbleLayout(
isLoggedInUser: Boolean,
innerQuote: Boolean,
isSimplified: Boolean,
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (popupExpanded: Boolean, onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.subtleBorder
val defaultBackground = MaterialTheme.colorScheme.background
val backgroundBubbleColor =
remember {
if (accountViewModel.isLoggedUser(note.author)) {
if (isLoggedInUser) {
mutableStateOf(
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
@@ -176,182 +275,176 @@ fun NormalChatNote(
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
val alignment: Arrangement.Horizontal =
remember {
if (accountViewModel.isLoggedUser(note.author)) {
Arrangement.End
} else {
Arrangement.Start
}
if (isLoggedInUser) {
Arrangement.End
} else {
Arrangement.Start
}
val shape: Shape =
remember {
if (accountViewModel.isLoggedUser(note.author)) {
ChatBubbleShapeMe
} else {
ChatBubbleShapeThem
if (isLoggedInUser) {
ChatBubbleShapeMe
} else {
ChatBubbleShapeThem
}
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = alignment,
) {
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
val showDetails =
remember {
mutableStateOf(
if (isSimplified) {
hasDetailsToShow
} else {
true
},
)
}
}
if (routeForLastRead != null) {
LaunchedEffect(key1 = routeForLastRead) {
accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt())
}
}
Column {
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = alignment,
) {
val availableBubbleSize = remember { mutableIntStateOf(0) }
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
val showDetails =
remember {
mutableStateOf(
if (accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED) {
note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty()
} else {
true
},
)
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (note.event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
} else {
if (accountViewModel.settings.featureSet == FeatureSetType.SIMPLIFIED) {
showDetails.value = !showDetails.value
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (!onClick()) {
if (isSimplified) {
showDetails.value = !showDetails.value
}
},
onLongClick = { popupExpanded = true },
)
}
Row(
horizontalArrangement = alignment,
modifier =
modif2.onSizeChanged {
if (availableBubbleSize.intValue != it.width) {
availableBubbleSize.intValue = it.width
}
},
) {
Surface(
color = backgroundBubbleColor.value,
shape = shape,
modifier = clickableModifier,
) {
RenderBubble(
note,
drawAuthorInfo,
alignment,
innerQuote,
backgroundBubbleColor,
onWantsToReply,
onWantsToEditDraft,
canPreview,
availableBubbleSize,
showDetails,
accountViewModel,
nav,
)
}
onLongClick = { popupExpanded = true },
)
}
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
onWantsToEditDraft = { onWantsToEditDraft(note) },
accountViewModel = accountViewModel,
nav = nav,
)
Row(
horizontalArrangement = alignment,
modifier = modif2,
) {
Surface(
color = backgroundBubbleColor.value,
shape = shape,
modifier = clickableModifier,
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier = StdTopPadding.then(Modifier.clickable(onClick = onAuthorClick)),
) {
drawAuthorLine()
}
}
inner(backgroundBubbleColor)
if (showDetails.value) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
detailRow()
}
}
}
}
}
actionMenu(popupExpanded) {
popupExpanded = false
}
}
}
@Preview
@Composable
private fun RenderBubble(
baseNote: Note,
drawAuthorInfo: Boolean,
alignment: Arrangement.Horizontal,
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
availableBubbleSize: MutableState<Int>,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val bubbleSize = remember { mutableIntStateOf(0) }
val bubbleModifier =
private fun BubblePreview() {
val backgroundBubbleColor =
remember {
Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp)
.onSizeChanged {
if (bubbleSize.intValue != it.width) {
bubbleSize.intValue = it.width
}
}
mutableStateOf<Color>(Color.Transparent)
}
Column(modifier = bubbleModifier) {
MessageBubbleLines(
drawAuthorInfo,
baseNote,
alignment,
availableBubbleSize,
innerQuote,
backgroundBubbleColor,
bubbleSize,
onWantsToReply,
onWantsToEditDraft,
canPreview,
showDetails,
accountViewModel,
nav,
)
Column {
ChatBubbleLayout(
isLoggedInUser = false,
innerQuote = false,
isSimplified = false,
hasDetailsToShow = true,
drawAuthorInfo = true,
parentBackgroundColor = backgroundBubbleColor,
onClick = { false },
onAuthorClick = {},
actionMenu = { popupExpanded, onDismiss ->
},
drawAuthorLine = {
UserDisplayNameLayout(
picture = {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(Size20dp).clip(CircleShape),
)
},
name = {
Text("Me", fontWeight = FontWeight.Bold)
},
)
},
detailRow = { Text("Relays and Actions") },
) { backgroundBubbleColor ->
Text("This is my note")
}
ChatBubbleLayout(
isLoggedInUser = true,
innerQuote = false,
isSimplified = false,
hasDetailsToShow = true,
drawAuthorInfo = true,
parentBackgroundColor = backgroundBubbleColor,
onClick = { false },
onAuthorClick = {},
actionMenu = { popupExpanded, onDismiss ->
},
drawAuthorLine = {
UserDisplayNameLayout(
picture = {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(Size20dp).clip(CircleShape),
)
},
name = {
Text("Me", fontWeight = FontWeight.Bold)
},
)
},
detailRow = { Text("Relays and Actions") },
) { backgroundBubbleColor ->
Text("This is a very long long loong note")
}
}
}
@Composable
private fun MessageBubbleLines(
drawAuthorInfo: Boolean,
baseNote: Note,
alignment: Arrangement.Horizontal,
availableBubbleSize: MutableState<Int>,
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
bubbleSize: MutableState<Int>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
if (drawAuthorInfo) {
DrawAuthorInfo(
baseNote,
alignment,
accountViewModel.settings.showProfilePictures.value,
accountViewModel,
nav,
)
}
if (baseNote.event !is DraftEvent) {
RenderReplyRow(
note = baseNote,
@@ -374,41 +467,6 @@ private fun MessageBubbleLines(
accountViewModel = accountViewModel,
nav = nav,
)
if (showDetails.value) {
ConstrainedStatusRow(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize,
firstColumn = {
if (baseNote.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(baseNote)
ChatTimeAgo(baseNote)
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
},
secondColumn = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
}
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
ReplyReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSizeModifier = Size15Modifier,
) {
onWantsToReply(baseNote)
}
}
Spacer(modifier = StdHorzSpacer)
},
)
}
}
@Composable
@@ -536,43 +594,9 @@ private fun RenderDraftEvent(
@Composable
private fun ConstrainedStatusRow(
bubbleSize: MutableState<Int>,
availableBubbleSize: MutableState<Int>,
firstColumn: @Composable () -> Unit,
secondColumn: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
with(LocalDensity.current) {
Modifier
.padding(top = Size5dp)
.height(Size20dp)
.widthIn(
bubbleSize.value.toDp(),
availableBubbleSize.value.toDp(),
)
},
) {
Column(modifier = ReactionRowHeightChat) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
firstColumn()
}
}
Column(modifier = ReactionRowHeightChat) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
secondColumn()
}
}
}
}
@Composable
@@ -581,10 +605,7 @@ fun IncognitoBadge(baseNote: Note) {
Icon(
painter = painterResource(id = R.drawable.incognito),
null,
modifier =
Modifier
.padding(top = 1.dp)
.size(14.dp),
modifier = incognitoIconModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = StdHorzSpacer)
@@ -592,10 +613,7 @@ fun IncognitoBadge(baseNote: Note) {
Icon(
painter = painterResource(id = R.drawable.incognito_off),
null,
modifier =
Modifier
.padding(top = 1.dp)
.size(14.dp),
modifier = incognitoIconModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = StdHorzSpacer)
@@ -642,6 +660,7 @@ private fun RenderRegularTextNote(
tags = tags,
backgroundColor = backgroundBubbleColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
@@ -655,6 +674,7 @@ private fun RenderRegularTextNote(
tags = EmptyTagList,
backgroundColor = backgroundBubbleColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
@@ -707,68 +727,56 @@ private fun RenderCreateChannelNote(note: Note) {
@Composable
private fun DrawAuthorInfo(
baseNote: Note,
alignment: Arrangement.Horizontal,
loadProfilePicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
baseNote.author?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier =
Modifier
.padding(top = Size10dp)
.clickable {
nav("User/${baseNote.author?.pubkeyHex}")
},
) {
WatchAndDisplayUser(it, loadProfilePicture, accountViewModel, nav)
}
WatchAndDisplayUser(it, accountViewModel, nav)
}
}
@Composable
fun UserDisplayNameLayout(
picture: @Composable () -> Unit,
name: @Composable () -> Unit,
) {
Box(chatAuthorBox, contentAlignment = Alignment.TopEnd) {
picture()
}
Spacer(modifier = StdHorzSpacer)
name()
}
@Composable
private fun WatchAndDisplayUser(
author: User,
loadProfilePicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userState by author.live().userMetadataInfo.observeAsState()
Box(chatAuthorBox, contentAlignment = Alignment.TopEnd) {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.picture,
userName = userState?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
)
UserDisplayNameLayout(
picture = {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.picture,
userName = userState?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
)
ObserveAndDisplayFollowingMark(author.pubkeyHex, Size5dp, accountViewModel)
}
if (userState != null) {
DisplayMessageUsername(userState?.bestName() ?: author.pubkeyDisplayHex(), userState?.tags ?: EmptyTagList)
} else {
DisplayMessageUsername(author.pubkeyDisplayHex(), EmptyTagList)
}
}
@Composable
private fun UserIcon(
pubkeyHex: String,
userProfilePicture: String?,
loadProfilePicture: Boolean,
) {
RobohashFallbackAsyncImage(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
loadProfilePicture = loadProfilePicture,
modifier = chatAuthorImage,
ObserveAndDisplayFollowingMark(author.pubkeyHex, Size5dp, accountViewModel)
},
name = {
if (userState != null) {
DisplayMessageUsername(userState?.bestName() ?: author.pubkeyDisplayHex(), userState?.tags ?: EmptyTagList)
} else {
DisplayMessageUsername(author.pubkeyDisplayHex(), EmptyTagList)
}
},
)
}
@@ -777,7 +785,6 @@ private fun DisplayMessageUsername(
userDisplayName: String,
userTags: ImmutableListOfLists<String>,
) {
Spacer(modifier = StdHorzSpacer)
CreateTextWithEmoji(
text = userDisplayName,
tags = userTags,

View File

@@ -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()
}
}

View File

@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
@@ -174,6 +175,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -303,8 +305,9 @@ fun AcceptableNote(
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel)
is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
@@ -562,11 +565,13 @@ fun NoteBody(
nav = nav,
)
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
if (!makeItShort) {
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
}
}
@@ -585,8 +590,8 @@ private fun RenderNoteRow(
val noteEvent = baseNote.event
when (noteEvent) {
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav)
is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
@@ -704,10 +709,10 @@ private fun RenderNoteRow(
nav,
)
}
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, accountViewModel)
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, accountViewModel)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
@@ -975,14 +980,16 @@ fun FirstUserInfoRow(
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor)
}
if (isRepost) {
BoostedMark()
} else if (isCommunityPost) {
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
if (isRepost) {
BoostedMark()
}
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
DisplayEditStatus(it)

View File

@@ -179,6 +179,7 @@ private fun OptionNote(
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
baseNote,
poolOption,
color,
canPreview,
@@ -217,6 +218,7 @@ private fun OptionNote(
@Composable
private fun RenderOptionAfterVote(
baseNote: Note,
poolOption: PollOption,
color: Color,
canPreview: Boolean,
@@ -276,7 +278,8 @@ private fun RenderOptionAfterVote(
Modifier,
tags,
backgroundColor,
poolOption.descriptor,
baseNote.idHex + poolOption.descriptor,
baseNote.toNostrUri(),
accountViewModel,
nav,
)

View File

@@ -100,6 +100,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -166,18 +167,12 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
val relayUrl =
walletConnectRelay.text
.ifBlank { null }
?.let {
var addedWSS =
if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it
if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1)
addedWSS
}
?.let { RelayUrlFormatter.normalize(it) }
val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) }
if (pubkeyHex != null) {
account?.changeZapPaymentRequest(
account.changeZapPaymentRequest(
Nip47WalletConnect.Nip47URI(
pubkeyHex,
relayUrl,

View File

@@ -236,6 +236,7 @@ fun RenderAppDefinition(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -60,18 +60,20 @@ import java.util.Locale
@Composable
fun RenderAudioTrack(
note: Note,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event as? AudioTrackEvent ?: return
AudioTrackHeader(noteEvent, note, accountViewModel, nav)
AudioTrackHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
}
@Composable
fun AudioTrackHeader(
noteEvent: AudioTrackEvent,
note: Note,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -140,21 +142,26 @@ fun AudioTrackHeader(
cover?.let { cover ->
LoadThumbAndThenVideoView(
videoUri = media,
mimeType = null,
title = noteEvent.subject(),
thumbUri = cover,
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = "nostr:${note.toNEvent()}",
accountViewModel = accountViewModel,
)
}
?: VideoView(
} ?: run {
VideoView(
videoUri = media,
mimeType = null,
title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -164,18 +171,20 @@ fun AudioTrackHeader(
@Composable
fun RenderAudioHeader(
note: Note,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event as? AudioHeaderEvent ?: return
AudioHeader(noteEvent, note, accountViewModel, nav)
AudioHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
}
@Composable
fun AudioHeader(
noteEvent: AudioHeaderEvent,
note: Note,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -195,10 +204,12 @@ fun AudioHeader(
) {
VideoView(
videoUri = media,
mimeType = null,
waveform = waveform,
title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
nostrUriCallback = note.toNostrUri(),
)
@@ -220,6 +231,7 @@ fun AudioHeader(
tags = tags,
backgroundColor = background,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
fun FileHeaderDisplay(
note: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
val event = (note.event as? FileHeaderEvent) ?: return
@@ -49,8 +50,9 @@ fun FileHeaderDisplay(
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = RichTextParser.isImageUrl(fullUrl)
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>(
if (isImage) {
@@ -61,6 +63,7 @@ fun FileHeaderDisplay(
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
)
} else {
MediaUrlVideo(
@@ -71,6 +74,7 @@ fun FileHeaderDisplay(
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
)
},
)
@@ -80,6 +84,7 @@ fun FileHeaderDisplay(
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
}

View File

@@ -42,6 +42,7 @@ import java.io.File
fun FileStorageHeaderDisplay(
baseNote: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
@@ -49,7 +50,7 @@ fun FileStorageHeaderDisplay(
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, isFiniteHeight, accountViewModel)
}
}
}
@@ -59,6 +60,7 @@ private fun ObserverAndRenderNIP95(
header: Note,
content: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
@@ -110,6 +112,7 @@ private fun ObserverAndRenderNIP95(
ZoomableContentView(
content = it,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
}

View File

@@ -188,6 +188,7 @@ private fun RenderGitPatchEvent(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
@@ -291,6 +292,7 @@ private fun RenderGitIssueEvent(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -100,15 +100,16 @@ fun DisplayHighlight(
}
TranslatableRichTextViewer(
quote,
content = quote,
canPreview = canPreview && !makeItShort,
quotesLeft,
Modifier.fillMaxWidth(),
EmptyTagList,
backgroundColor,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = quote,
accountViewModel,
nav,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
)
DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)

View File

@@ -152,10 +152,12 @@ fun RenderLiveActivityEventInner(
) {
VideoView(
videoUri = media,
mimeType = null,
title = subject,
artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
)

View File

@@ -138,6 +138,7 @@ fun RenderNIP90ContentDiscoveryResponse(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -65,22 +64,18 @@ fun RenderPoll(
val showReply by
remember(note) {
derivedStateOf {
noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
!makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
if (showReply) {
val replyingDirectlyTo =
remember(note) {
if (noteEvent is BaseTextNoteEvent) {
val replyingTo = noteEvent.replyingToAddressOrEvent()
if (replyingTo != null) {
val newNote = accountViewModel.getNoteIfExists(replyingTo)
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
newNote
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
val replyingTo = noteEvent.replyingToAddressOrEvent()
if (replyingTo != null) {
val newNote = accountViewModel.getNoteIfExists(replyingTo)
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
newNote
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
@@ -116,6 +111,7 @@ fun RenderPoll(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -115,6 +115,7 @@ fun RenderPrivateMessage(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
@@ -144,6 +145,7 @@ fun RenderPrivateMessage(
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -76,6 +76,7 @@ fun RenderReport(
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,

View File

@@ -149,6 +149,7 @@ fun RenderTextEvent(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -119,6 +119,7 @@ fun RenderTextModificationEvent(
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -66,6 +66,7 @@ fun VideoDisplay(
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -84,8 +85,9 @@ fun VideoDisplay(
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifBlank { null } ?: event.alt()
val isImage = RichTextParser.isImageUrl(fullUrl)
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>(
if (isImage) {
@@ -96,6 +98,7 @@ fun VideoDisplay(
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
)
} else {
MediaUrlVideo(
@@ -106,6 +109,7 @@ fun VideoDisplay(
uri = uri,
authorName = note.author?.toBestDisplayName(),
artworkUri = event.thumb() ?: event.image(),
mimeType = mimeType,
)
},
)
@@ -143,6 +147,7 @@ fun VideoDisplay(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
}
@@ -169,6 +174,7 @@ fun VideoDisplay(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.VideoEvent
@Composable
fun JustVideoDisplay(
note: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
val event = (note.event as? VideoEvent) ?: return
val fullUrl = event.url() ?: return
val content by
remember(note) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>(
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
)
},
)
}
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
}
}

View File

@@ -254,7 +254,7 @@ private fun FeedLoaded(
NoteCompose(
item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
modifier = Modifier.fillMaxWidth(),
isBoostedNote = false,
isHiddenFeed = state.showHidden.value,
quotesLeft = 3,

View File

@@ -504,17 +504,17 @@ fun NoteMaster(
nav = nav,
)
} else if (noteEvent is VideoEvent) {
VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav)
VideoDisplay(baseNote, false, true, backgroundColor, false, accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote, true, accountViewModel)
FileHeaderDisplay(baseNote, true, false, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
} else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav)
AudioTrackHeader(noteEvent, baseNote, false, accountViewModel, nav)
} else if (noteEvent is AudioHeaderEvent) {
AudioHeader(noteEvent, baseNote, accountViewModel, nav)
AudioHeader(noteEvent, baseNote, false, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,

View File

@@ -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,

View File

@@ -743,6 +743,7 @@ fun ShowVideoStreaming(
ZoomableContentView(
content = zoomableUrlVideo,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}

View File

@@ -86,7 +86,7 @@ fun ChatroomListScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val windowSizeClass = accountViewModel.settings.windowSizeClass.value
val windowSizeClass by accountViewModel.settings.windowSizeClass
val twoPane by remember {
derivedStateOf {
@@ -104,7 +104,7 @@ fun ChatroomListScreen(
ChatroomListTwoPane(
knownFeedViewModel = knownFeedViewModel,
newFeedViewModel = newFeedViewModel,
widthSizeClass = windowSizeClass.widthSizeClass,
widthSizeClass = windowSizeClass!!.widthSizeClass,
accountViewModel = accountViewModel,
nav = nav,
)

View File

@@ -359,30 +359,20 @@ private fun DiscoverFeedLoaded(
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() }
// TODO For now we avoid subscription based DVMs, as we need logic for these first if a user is not subscribed already.
var avoid = false
if (item.event is AppDefinitionEvent) {
if ((item.event as AppDefinitionEvent).appMetaData()?.subscription == true) {
avoid = true
}
}
// TODO End
if (!avoid) {
Row(defaultModifier) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier.fillMaxWidth(),
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
Row(defaultModifier) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier.fillMaxWidth(),
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -325,12 +327,14 @@ private fun RenderVideoOrPictureNote(
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxSize(1f), verticalArrangement = Arrangement.Center) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
val noteEvent = remember { note.event }
if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note, false, accountViewModel)
FileHeaderDisplay(note, false, true, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note, false, accountViewModel)
FileStorageHeaderDisplay(note, false, true, accountViewModel)
} else if (noteEvent is VideoEvent) {
JustVideoDisplay(note, false, true, accountViewModel)
}
}
}

View File

@@ -96,6 +96,8 @@ val Size35dp = 35.dp
val Size40dp = 40.dp
val Size55dp = 55.dp
val Size75dp = 75.dp
val Size110dp = 110.dp
val Size165dp = 165.dp
val HalfEndPadding = Modifier.padding(end = 5.dp)
val HalfStartPadding = Modifier.padding(start = 5.dp)
@@ -108,6 +110,7 @@ val StdPadding = Modifier.padding(10.dp)
val BigPadding = Modifier.padding(15.dp)
val RowColSpacing = Arrangement.spacedBy(3.dp)
val RowColSpacing5dp = Arrangement.spacedBy(5.dp)
val HalfHorzPadding = Modifier.padding(horizontal = 5.dp)
val HalfVertPadding = Modifier.padding(vertical = 5.dp)
@@ -236,9 +239,19 @@ val chatAuthorBox = Modifier.size(20.dp)
val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape)
val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp)
val messageDetailsModifier = Modifier.height(Size25dp)
val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp)
val inlinePlaceholder =
Placeholder(
width = Font17SP,
height = Font17SP,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
)
val incognitoIconModifier =
Modifier
.padding(top = 1.dp)
.size(14.dp)
val hashVerifierMark = Modifier.width(40.dp).height(40.dp).padding(10.dp)

View File

@@ -113,6 +113,7 @@
<string name="website_url">URL webové stránky</string>
<string name="ln_address">LN adresa</string>
<string name="ln_url_outdated">LN URL (zastaralé)</string>
<string name="save_to_gallery">Uložit do galerie</string>
<string name="image_saved_to_the_gallery">Obrázek uložen do galerie</string>
<string name="failed_to_save_the_image">Chyba při ukládání obrázku</string>
<string name="upload_image">Nahrát obrázek</string>
@@ -405,6 +406,7 @@
<string name="languages">Jazyky</string>
<string name="tags">Tagy</string>
<string name="posting_policy">Politika příspěvků</string>
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
<string name="message_length">Délka zprávy</string>
<string name="subscriptions">Předplatné</string>
<string name="filters">Filtry</string>
@@ -498,6 +500,7 @@
<string name="load_image_description">Kdy načíst obrázek</string>
<string name="copy_to_clipboard">Kopírovat do schránky</string>
<string name="copy_npub_to_clipboard">Kopírovat npub do schránky</string>
<string name="share_or_save">Sdílet nebo Uložit</string>
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
<string name="created_at">Vytvořeno</string>
@@ -685,16 +688,21 @@
<string name="search_relays_not_found_description">Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky.</string>
<string name="search_relays_not_found_editing">Vložte mezi 13 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Nastavení relé</string>
<string name="public_home_section">Veřejná domácí relé</string>
<string name="public_home_section_explainer">Tento typ relé ukládá veškerý váš obsah. Amethyst sem pošle vaše příspěvky a ostatní použijí tato relé, aby našli váš obsah. Vložte mezi 13 relé. Mohou to být osobní relé, placená relé nebo veřejná relé.</string>
<string name="public_notif_section">Veřejná schránka relé</string>
<string name="public_notif_section_explainer">Tento typ relé přijímá všechny odpovědi, komentáře, lajky a zaps k vašim příspěvkům. Vložte mezi 13 relé a ujistěte se, že přijímají příspěvky od kohokoli.</string>
<string name="private_inbox_section">DM schránka relé</string>
<string name="private_inbox_section_explainer">Vložte mezi 13 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené)</string>
<string name="private_outbox_section">Soukromá relé</string>
<string name="private_outbox_section_explainer">Vložte mezi 13 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele.</string>
<string name="kind_3_section">Obecná relé</string>
<string name="kind_3_section_description">Amethyst používá tato relé ke stahování příspěvků pro vás.</string>
<string name="search_section">Vyhledávací relé</string>
<string name="search_section_explainer">Seznam relé, která se používají k vyhledávání a označování uživatelů. Označování a vyhledávání nebude fungovat, pokud nejsou k dispozici žádné možnosti.</string>
<string name="local_section">Lokální relé</string>
<string name="local_section_explainer">Seznam relací, které jsou v tomto zařízení spuštěny.</string>
<string name="zap_the_devs_title">Zapni vývojáře!</string>
<string name="zap_the_devs_description">Váš příspěvek nám pomáhá dělat rozdíl. Každý sat se počítá!</string>
<string name="donate_now">Přispět nyní</string>

View File

@@ -113,6 +113,7 @@
<string name="website_url">Website-URL</string>
<string name="ln_address">LN-Adresse</string>
<string name="ln_url_outdated">LN-URL (veraltet)</string>
<string name="save_to_gallery">In die Galerie abspeichern</string>
<string name="image_saved_to_the_gallery">Bild in der Gal
erie gespeichert</string>
@@ -410,6 +411,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="languages">Sprachen</string>
<string name="tags">Tags</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
<string name="message_length">Nachrichtenlänge</string>
<string name="subscriptions">Abonnements</string>
<string name="filters">Filter</string>
@@ -503,6 +505,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="load_image_description">Wann Bilder geladen werden sollen</string>
<string name="copy_to_clipboard">In Zwischenablage kopieren</string>
<string name="copy_npub_to_clipboard">Npub in Zwischenablage kopieren</string>
<string name="share_or_save">Teilen oder Speichern</string>
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
<string name="created_at">Erstellt am</string>
@@ -690,16 +693,21 @@ anz der Bedingungen ist erforderlich</string>
<string name="search_relays_not_found_description">Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern.</string>
<string name="search_relays_not_found_editing">Fügen Sie 13 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Relaiseinstellungen</string>
<string name="public_home_section">Öffentliche Heimrelais</string>
<string name="public_home_section_explainer">Dieser Relais-Typ speichert alle Ihre Inhalte. Amethyst sendet Ihre Beiträge hierher und andere werden diese Relais verwenden, um Ihre Inhalte zu finden. Fügen Sie 13 Relais ein. Sie können persönliche Relais, bezahlte Relais oder öffentliche Relais sein.</string>
<string name="public_notif_section">Öffentliche Posteingangsrelais</string>
<string name="public_notif_section_explainer">Dieser Relais-Typ empfängt alle Antworten, Kommentare, Likes und Zaps auf Ihre Beiträge. Fügen Sie 13 Relais ein und stellen Sie sicher, dass sie Beiträge von jedem akzeptieren.</string>
<string name="private_inbox_section">DM-Posteingangsrelais</string>
<string name="private_inbox_section_explainer">Fügen Sie 13 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt)</string>
<string name="private_outbox_section">Private Relais</string>
<string name="private_outbox_section_explainer">Fügen Sie zwischen 13 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen.</string>
<string name="kind_3_section">Allgemeine Relais</string>
<string name="kind_3_section_description">Amethyst verwendet diese Relais, um Beiträge für Sie herunterzuladen.</string>
<string name="search_section">Suchrelais</string>
<string name="search_section_explainer">Liste der Relais, die für die Suche und das Taggen von Benutzern verwendet werden. Das Taggen und die Suche funktionieren nicht, wenn keine Optionen verfügbar sind.</string>
<string name="local_section">Lokale Relais</string>
<string name="local_section_explainer">Liste der Relays, die auf diesem Gerät laufen.</string>
<string name="zap_the_devs_title">Zap die Entwickler!</string>
<string name="zap_the_devs_description">Deine Spende hilft uns, einen Unterschied zu machen. Jeder Sat zählt!</string>
<string name="donate_now">Jetzt spenden</string>

View File

@@ -15,6 +15,7 @@
<string name="group_picture">Imagen de grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
<string name="other">Otro</string>
@@ -93,6 +94,7 @@
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
<string name="private_message_feed">Fuente de mensajes privados</string>
<string name="public_chat_feed">Fuente de chat público</string>
@@ -390,6 +392,8 @@
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
<string name="read_from_relay">Relé de lectura</string>
<string name="write_to_relay">Relé de escritura</string>
<string name="write_to_relay_description">La cantidad en bytes que se enviaron a este relé, incluyendo filtros y eventos</string>
<string name="read_from_relay_description">La cantidad en bytes que se recibieron de este relé, incluyendo filtros y eventos</string>
<string name="an_error_occurred_trying_to_get_relay_information">Se ha producido un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
@@ -429,6 +433,7 @@
<string name="are_you_sure_you_want_to_log_out">Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar?</string>
<string name="followed_tags">Etiquetas seguidas</string>
<string name="relay_setup">Relés</string>
<string name="discover_content">Descubrimiento de notas</string>
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
@@ -669,6 +674,34 @@
<string name="show_npub_as_a_qr_code">Mostrar npub como código QR</string>
<string name="invalid_nip19_uri">Dirección inválida</string>
<string name="invalid_nip19_uri_description">Amethyst recibió un URI para abrir pero era inválido: %1$s</string>
<string name="dm_relays_title">Relés de buzón de entrada para mensajes directos</string>
<string name="dm_relays_through">Relés: %1$s</string>
<string name="dm_relays_regular">Usando relés normales</string>
<string name="dm_relays_not_found">Configura tus relés de buzón de entrada privados</string>
<string name="dm_relays_not_found_description">Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes.</string>
<string name="dm_relays_not_found_examples">Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="dm_relays_not_found_editing">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos.</string>
<string name="dm_relays_not_found_create_now">Configurar ahora</string>
<string name="search_relays_title">Relés de búsqueda</string>
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Configuración de relés</string>
<string name="public_home_section">Relés de inicio públicos</string>
<string name="public_home_section_explainer">Este tipo de relé almacena todo tu contenido. Amethyst enviará tus publicaciones aquí y otros usarán estos relés para encontrar tu contenido. Inserta entre 1 y 3 relés. Pueden ser personales, de pago o públicos.</string>
<string name="public_notif_section">Relés de buzón de entrada públicos</string>
<string name="public_notif_section_explainer">Este tipo de relé recibe todas las respuestas, comentarios, me gusta y zaps de tus publicaciones. Inserta entre 1 y 3 relés y asegúrate de que acepten publicaciones de cualquier persona.</string>
<string name="private_inbox_section">Relés de buzón de entrada para mensajes directos</string>
<string name="private_inbox_section_explainer">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Otros usarán estos relés para enviarte mensajes directos. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="private_outbox_section">Relés privados</string>
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
<string name="local_section_explainer">Lista de relés que se utilizan en este dispositivo.</string>
<string name="zap_the_devs_title">¡Zapea a los desarrolladores!</string>
<string name="zap_the_devs_description">Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta!</string>
<string name="donate_now">Donar ahora</string>
@@ -706,4 +739,9 @@
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
<string name="load_from_text">Del msj</string>
<string name="dvm_looking_for_app">Buscando aplicación</string>
<string name="dvm_waiting_status">Trabajo solicitado, esperando respuesta</string>
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu monedero</string>
<string name="dvm_waiting_to_confim_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
</resources>

View File

@@ -15,6 +15,7 @@
<string name="group_picture">Foto de grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
<string name="other">Otro</string>
@@ -93,6 +94,7 @@
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
<string name="private_message_feed">Feed de mensajes privados</string>
<string name="public_chat_feed">Feed del chat público</string>
@@ -390,6 +392,8 @@
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
<string name="read_from_relay">Relé de lectura</string>
<string name="write_to_relay">Relé de escritura</string>
<string name="write_to_relay_description">La cantidad en bytes que se enviaron a este relé, incluyendo filtros y eventos</string>
<string name="read_from_relay_description">La cantidad en bytes que se recibieron de este relé, incluyendo filtros y eventos</string>
<string name="an_error_occurred_trying_to_get_relay_information">Se produjo un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
@@ -429,6 +433,7 @@
<string name="are_you_sure_you_want_to_log_out">Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar?</string>
<string name="followed_tags">Etiquetas seguidas</string>
<string name="relay_setup">Relés</string>
<string name="discover_content">Descubrimiento de notas</string>
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
@@ -669,6 +674,34 @@
<string name="show_npub_as_a_qr_code">Mostrar npub como código QR</string>
<string name="invalid_nip19_uri">Dirección inválida</string>
<string name="invalid_nip19_uri_description">Amethyst recibió un URI para abrir pero era inválido: %1$s</string>
<string name="dm_relays_title">Relés de buzón de entrada para mensajes directos</string>
<string name="dm_relays_through">Relés: %1$s</string>
<string name="dm_relays_regular">Usando relés normales</string>
<string name="dm_relays_not_found">Configura tus relés de buzón de entrada privados</string>
<string name="dm_relays_not_found_description">Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes.</string>
<string name="dm_relays_not_found_examples">Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="dm_relays_not_found_editing">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos.</string>
<string name="dm_relays_not_found_create_now">Configurar ahora</string>
<string name="search_relays_title">Relés de búsqueda</string>
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Configuración de relés</string>
<string name="public_home_section">Relés de inicio públicos</string>
<string name="public_home_section_explainer">Este tipo de relé almacena todo tu contenido. Amethyst enviará tus publicaciones aquí y otros usarán estos relés para encontrar tu contenido. Inserta entre 1 y 3 relés. Pueden ser personales, de pago o públicos.</string>
<string name="public_notif_section">Relés de buzón de entrada públicos</string>
<string name="public_notif_section_explainer">Este tipo de relé recibe todas las respuestas, comentarios, me gusta y zaps de tus publicaciones. Inserta entre 1 y 3 relés y asegúrate de que acepten publicaciones de cualquier persona.</string>
<string name="private_inbox_section">Relés de buzón de entrada para mensajes directos</string>
<string name="private_inbox_section_explainer">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Otros usarán estos relés para enviarte mensajes directos. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="private_outbox_section">Relés privados</string>
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
<string name="local_section_explainer">Lista de relés que se utilizan en este dispositivo.</string>
<string name="zap_the_devs_title">¡Zapea a los desarrolladores!</string>
<string name="zap_the_devs_description">Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta!</string>
<string name="donate_now">Donar ahora</string>
@@ -706,4 +739,9 @@
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
<string name="load_from_text">Del msj</string>
<string name="dvm_looking_for_app">Buscando aplicación</string>
<string name="dvm_waiting_status">Trabajo solicitado, esperando respuesta</string>
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu billetera</string>
<string name="dvm_waiting_to_confim_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
</resources>

View File

@@ -15,6 +15,7 @@
<string name="group_picture">Imagen del grupo</string>
<string name="explicit_content">Contenido explícito</string>
<string name="spam">Spam</string>
<string name="spam_description">El número de eventos de spam procedentes de este relé</string>
<string name="impersonation">Suplantación de identidad</string>
<string name="illegal_behavior">Comportamiento ilegal</string>
<string name="other">Otro</string>
@@ -93,6 +94,7 @@
<string name="posts">Publicaciones</string>
<string name="bytes">Bytes</string>
<string name="errors">Errores</string>
<string name="errors_description">El número de errores de conexión en esta sesión</string>
<string name="home_feed">Tus noticias</string>
<string name="private_message_feed">Feed de mensajes privados</string>
<string name="public_chat_feed">Feed del chat público</string>
@@ -390,6 +392,8 @@
<string name="sats_to_complete">Zapraiser en %1$s. %2$s sats hasta la meta</string>
<string name="read_from_relay">Relé de lectura</string>
<string name="write_to_relay">Relé de escritura</string>
<string name="write_to_relay_description">La cantidad en bytes que se enviaron a este relé, incluyendo filtros y eventos</string>
<string name="read_from_relay_description">La cantidad en bytes que se recibieron de este relé, incluyendo filtros y eventos</string>
<string name="an_error_occurred_trying_to_get_relay_information">Se produjo un error al intentar obtener información sobre el relé de %1$s</string>
<string name="owner">Propietario</string>
<string name="version">Versión</string>
@@ -429,6 +433,7 @@
<string name="are_you_sure_you_want_to_log_out">Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar?</string>
<string name="followed_tags">Etiquetas seguidas</string>
<string name="relay_setup">Relés</string>
<string name="discover_content">Descubrimiento de notas</string>
<string name="discover_marketplace">Mercado</string>
<string name="discover_live">En vivo</string>
<string name="discover_community">Comunidad</string>
@@ -669,6 +674,34 @@
<string name="show_npub_as_a_qr_code">Mostrar npub como código QR</string>
<string name="invalid_nip19_uri">Dirección inválida</string>
<string name="invalid_nip19_uri_description">Amethyst recibió un URI para abrir pero era inválido: %1$s</string>
<string name="dm_relays_title">Relés de buzón de entrada para mensajes directos</string>
<string name="dm_relays_through">Relés: %1$s</string>
<string name="dm_relays_regular">Usando relés normales</string>
<string name="dm_relays_not_found">Configura tus relés de buzón de entrada privados</string>
<string name="dm_relays_not_found_description">Esta configuración permite que todo el mundo sepa qué relés usar cuando te envíen mensajes. Sin ellos, es posible que no recibas algunos mensajes.</string>
<string name="dm_relays_not_found_examples">Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="dm_relays_not_found_editing">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos.</string>
<string name="dm_relays_not_found_create_now">Configurar ahora</string>
<string name="search_relays_title">Relés de búsqueda</string>
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Configuración de relés</string>
<string name="public_home_section">Relés de inicio públicos</string>
<string name="public_home_section_explainer">Este tipo de relé almacena todo tu contenido. Amethyst enviará tus publicaciones aquí y otros usarán estos relés para encontrar tu contenido. Inserta entre 1 y 3 relés. Pueden ser personales, de pago o públicos.</string>
<string name="public_notif_section">Relés de buzón de entrada públicos</string>
<string name="public_notif_section_explainer">Este tipo de relé recibe todas las respuestas, comentarios, me gusta y zaps de tus publicaciones. Inserta entre 1 y 3 relés y asegúrate de que acepten publicaciones de cualquier persona.</string>
<string name="private_inbox_section">Relés de buzón de entrada para mensajes directos</string>
<string name="private_inbox_section_explainer">Inserta entre 1 y 3 relés para que te sirvan de buzón de entrada privado. Otros usarán estos relés para enviarte mensajes directos. Los relés del buzón de entrada para mensajes directos deberían aceptar cualquier mensaje de cualquier persona, pero solo te permiten descargarlos. Algunas buenas opciones son:\n - inbox.nostr.wine (de pago)\n - you.nostr1.com (relés personales; de pago)</string>
<string name="private_outbox_section">Relés privados</string>
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
<string name="local_section_explainer">Lista de relés que se utilizan en este dispositivo.</string>
<string name="zap_the_devs_title">¡Zapea a los desarrolladores!</string>
<string name="zap_the_devs_description">Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta!</string>
<string name="donate_now">Donar ahora</string>
@@ -706,4 +739,9 @@
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
<string name="load_from_text">Del msj</string>
<string name="dvm_looking_for_app">Buscando aplicación</string>
<string name="dvm_waiting_status">Trabajo solicitado, esperando respuesta</string>
<string name="dvm_requesting_job">Solicitando trabajo a DVM</string>
<string name="nwc_payment_request">Solicitud de pago enviada, esperando confirmación de tu billetera</string>
<string name="dvm_waiting_to_confim_payment">Esperando a que DVM confirme el pago o envíe resultados</string>
</resources>

View File

@@ -1,20 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">क्यूआर क्रमचित्र के अभिमुख करें</string>
<string name="show_qr">क्यूआर क्रमचित्र दिखाएँ</string>
<string name="point_to_the_qr_code">क्यूआर क्रमचित्र के अभिमुख करें</string>
<string name="show_qr">क्यूआर क्रमचित्र दिखाएँ</string>
<string name="profile_image">परिचय चित्र</string>
<string name="your_profile_image">आपका परिचय चित्र</string>
<string name="scan_qr">क्यूआर क्रमचित्र को परखें</string>
<string name="scan_qr">क्यूआर क्रमचित्र को परखें</string>
<string name="show_anyway">फिर भी दिखाएँ</string>
<string name="post_was_hidden">इस प्रकाशित पत्र को छिपाया गया क्योंकि इसमें आपके आच्छाद्य उपयोगकर्ता अथवा शब्द उल्लेखित हैं</string>
<string name="post_was_flagged_as_inappropriate_by">प्रकाशित पत्र को मौन किया गया अथवा सूचित किया गया इनके द्वारा</string>
<string name="post_not_found">घटना पुष्ट किया जा रहा है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं</string>
<string name="post_not_found">घटना उपलब्ध किया जा रहा है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं</string>
<string name="channel_image">प्रणाली चित्र</string>
<string name="referenced_event_not_found">उद्धृत घटना अप्राप्त</string>
<string name="could_not_decrypt_the_message">सन्देश का अरहस्यीकरण असफल</string>
<string name="group_picture">समूह चित्र</string>
<string name="explicit_content">अभद्र विषयवस्तु</string>
<string name="spam">कचरालेख</string>
<string name="spam_description">इस पुनःप्रसारक से कचरालेख घटनाओं की संख्या</string>
<string name="impersonation">पररूपण</string>
<string name="illegal_behavior">अवैध बरताव</string>
<string name="other">अन्य</string>
@@ -47,7 +48,7 @@
<string name="zaps">ज्साप</string>
<string name="view_count">दृष्ट संख्या</string>
<string name="boost">उद्धृत करें</string>
<string name="boosted">उद्धृत किया गया</string>
<string name="boosted">उद्धृत</string>
<string name="edited">सम्पादित</string>
<string name="edited_number">सम्पादन #%1$s</string>
<string name="original">मूल</string>
@@ -62,7 +63,7 @@
<string name="profile_banner">परिचय पृष्ठ चौडाचित्र</string>
<string name="payment_successful">भुगतान सफल</string>
<string name="error_parsing_error_message">अपक्रम संदेश परखने में अपक्रम</string>
<string name="following">" अनुचरण चालू"</string>
<string name="following">" का अनुचरण"</string>
<string name="followers">" अनुचर"</string>
<string name="profile">परिचय</string>
<string name="security_filters">सुरक्षा छलनियाँ</string>
@@ -85,14 +86,15 @@
<string name="about_us">"हमारा परिचय.. "</string>
<string name="what_s_on_your_mind">आपके मन में क्या है?</string>
<string name="post">पत्र प्रकाशन</string>
<string name="save">अभिलेखित करें</string>
<string name="save">अभिलेख करें</string>
<string name="create">बनाएँ</string>
<string name="cancel">निरस्त करें</string>
<string name="failed_to_upload_the_image">चित्र आरोहण असफल</string>
<string name="relay_address">पुनःप्रसारक पता</string>
<string name="posts">पत्र</string>
<string name="posts">्रकाशित पत्र</string>
<string name="bytes">अष्टक</string>
<string name="errors">अपक्रम</string>
<string name="errors_description">संयोजन अपक्रमों की संख्या इस सत्र में</string>
<string name="home_feed">मुख्य सूचनावली</string>
<string name="private_message_feed">निजी संदेश सूचनावली</string>
<string name="public_chat_feed">सार्वजनिक चर्चा सूचनावली</string>
@@ -102,7 +104,7 @@
<string name="display_name">प्रदर्शन नाम</string>
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
<string name="my_awesome_name">उष्ट्रपक्षी मक्बढिया</string>
<string name="welcome">स्वागतम उष्ट्रपक्षी</string>
<string name="welcome">स्वागतम उष्ट्रपक्षी!</string>
<string name="username">उपयोगकर्ता नाम</string>
<string name="my_username">मेरा उपयोगकर्ता नाम</string>
<string name="about_me">मेरा परिचय</string>
@@ -111,8 +113,9 @@
<string name="website_url">जालस्थान पता</string>
<string name="ln_address">लै॰जाल पता</string>
<string name="ln_url_outdated">लै॰जाल पता (पुराना)</string>
<string name="image_saved_to_the_gallery">चित्र को चित्रालय क्रमक में रख दिया गया</string>
<string name="failed_to_save_the_image">चित्र को रखने में असफल</string>
<string name="save_to_gallery">चित्रालय में अभिलेखन करें</string>
<string name="image_saved_to_the_gallery">चित्र का अभिलेखन किया गया चित्रालय क्रमक में</string>
<string name="failed_to_save_the_image">चित्र का अभिलेखन असफल</string>
<string name="upload_image">चित्र आरोहण</string>
<string name="uploading">आरोहण चल रहा है…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
@@ -125,19 +128,19 @@
<string name="new_requests">नये अनुरोध</string>
<string name="blocked_users">बाधित उपयोगकर्ता</string>
<string name="new_threads">नये सूत्र</string>
<string name="conversations">वाद</string>
<string name="conversations">म्वाद</string>
<string name="notes">टीकाएँ</string>
<string name="replies">उत्तरें</string>
<string name="follows">"अनुचरण कर्ता है"</string>
<string name="replies">प्रतिवचन</string>
<string name="follows">"का अनुचरण"</string>
<string name="reports">"सूचनाएँ"</string>
<string name="more_options">अधिक विकल्प</string>
<string name="relays">" पुनःप्रसारक"</string>
<string name="website">जालस्थान</string>
<string name="lightning_address">लैटनिंग पता</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">NSEC विभेदक (आपका गुप्त पारणशब्द) क अनुकरण करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">एनसेक॰ विभेदक (आपका गुप्त पारणशब्द) क अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
<string name="copy_private_key_to_the_clipboard">गुप्त कुंचिका की अनुकृति करें टाँकाफलक में</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">ख्याप्य कुंचिका की अनुकृति करता है टाँकाफलक में बाँटने के लिए</string>
<string name="copy_public_key_npub_to_the_clipboard">ख्याप्य कुंचिका (NPub) क अनुकरण करें टाँकाफलक में</string>
<string name="copy_public_key_npub_to_the_clipboard">ख्याप्य कुंचिका (एनपुब॰) क अनुकृति करें टाँकाफलक में</string>
<string name="send_a_direct_message">सीधा संदेश भेजें</string>
<string name="edits_the_user_s_metadata">उपयोगकर्ता के उपतथ्य का सम्पादन करता है</string>
<string name="follow">अनुचरण करें</string>
@@ -145,10 +148,10 @@
<string name="unblock">बाधा हटाएँ</string>
<string name="copy_user_id">उपयोगकर्ता विभेदक की अनुकृति करें</string>
<string name="unblock_user">उपयोगकर्ता बाधा हटाएँ</string>
<string name="npub_hex_username">"npub, उपयोगकर्ता नाम, लेख"</string>
<string name="npub_hex_username">"एनपुब॰, उपयोगकर्ता नाम, लेख"</string>
<string name="clear">रिक्त करें</string>
<string name="app_logo">क्रमक चिह्न</string>
<string name="nsec_npub_hex_private_key">nsec.. अथवा npub..</string>
<string name="nsec_npub_hex_private_key">एनसेक॰.. अथवा एनपुब॰..</string>
<string name="ncryptsec_password">कुंचिका को खोलने के लिए पारणशब्द</string>
<string name="show_password">पारणशब्द दिखाएँ</string>
<string name="hide_password">पारणशब्द छिपाएँ</string>
@@ -168,9 +171,9 @@
<string name="already_have_an_account">क्या नोस्ट्र लेखा पहले से है?</string>
<string name="create_a_new_account">नया लेखा बनाएँ</string>
<string name="generate_a_new_key">नयी कुंचिका बनाएँ</string>
<string name="loading_feed">सूचनावली प्राप्त किया जा रह है</string>
<string name="loading_account">लेखा प्राप्त किया जा रह है</string>
<string name="error_loading_replies">"उत्तरों को प्राप्त करने में अपक्रम "</string>
<string name="loading_feed">सूचनावली प्राप्त क जा रह है</string>
<string name="loading_account">लेखा जानकारी प्राप्त क जा रह है</string>
<string name="error_loading_replies">"प्रतिवचनों को प्राप्त करने में अपक्रम : "</string>
<string name="try_again">पुनः प्रयास करें</string>
<string name="feed_is_empty">सूचनावली रिक्त है।</string>
<string name="refresh">नवीकरण</string>
@@ -183,7 +186,7 @@
<string name="leave">निर्गमन</string>
<string name="unfollow">अनुचरण ना करें</string>
<string name="channel_created">प्रणाली बनायी गयी</string>
<string name="channel_information_changed_to">"प्रणाली सूचना बना दिया किया गया इसे"</string>
<string name="channel_information_changed_to">"प्रणाली जानकारी परिवर्तित की गयी"</string>
<string name="public_chat">सार्वजनिक चर्चा</string>
<string name="posts_received">पत्र प्राप्त</string>
<string name="remove">हटाएँ</string>
@@ -196,9 +199,9 @@
<string name="nip_05">नोस्ट्र पता</string>
<string name="never">कभी नहीं</string>
<string name="now">अभी</string>
<string name="h"></string>
<string name="m"></string>
<string name="d"></string>
<string name="h"> घं॰</string>
<string name="m"> मि॰</string>
<string name="d"> दि॰</string>
<string name="nudity">नग्नता</string>
<string name="profanity_hateful_speech">अपशब्द / द्वेषपूर्ण भाषण</string>
<string name="report_hateful_speech">द्वेषपूर्ण भाषण की सूचना दें</string>
@@ -206,10 +209,10 @@
<string name="others">अन्य</string>
<string name="mark_all_known_as_read">सब ज्ञात को पढ लिया चिह्नित करें</string>
<string name="mark_all_new_as_read">सब नये को पढ लिया चिह्नित करें</string>
<string name="mark_all_as_read">सब पढ लिया चिह्नित करें</string>
<string name="mark_all_as_read">सब को पढ लिया चिह्नित करें</string>
<string name="backup_keys">कुंचिकाएँ सुरक्षित रखें</string>
<string name="account_backup_tips2_md" tools:ignore="Typos">## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव
\n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाश कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा।
\n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा।
\n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो।
\n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे।
\n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं।
@@ -218,7 +221,7 @@
\n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते।
</string>
<string name="failed_to_encrypt_key">आपकी निजी कुंचिका का रहस्यीकरण असफल</string>
<string name="secret_key_copied_to_clipboard">गुप्त कुंचिका (nsec) क अनुकरण टाँकाफलक में डाला गया।</string>
<string name="secret_key_copied_to_clipboard">गुप्त कुंचिका (एनसेक॰) क अनुकृति टाँकाफलक में किया गया।</string>
<string name="copy_my_secret_key">मेरी गुप्त कुंचिका की अनुकृति करें</string>
<string name="encrypt_and_copy_my_secret_key">मेरी गुप्त कुंचिका का रहस्यीकरण तथा अनुकृति करें</string>
<string name="biometric_authentication_failed">प्रमाणीकरण असफल</string>
@@ -275,21 +278,21 @@
<string name="report_dialog_post_report_btn">सूचना प्रकाशित करें</string>
<string name="report_dialog_title">बाधित करें तथा सूचना दें</string>
<string name="block_only">बाधित करें</string>
<string name="bookmarks">स्मारकचिह्न</string>
<string name="bookmarks">स्मर्तव्य</string>
<string name="drafts">पाण्डुलिपियाँ</string>
<string name="private_bookmarks">निजी स्मारकचिह्नित वस्तु</string>
<string name="public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु</string>
<string name="add_to_private_bookmarks">निजी स्मारकचिह्नित वस्तु सूची में जोडें</string>
<string name="add_to_public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु सूची में जोडें</string>
<string name="remove_from_private_bookmarks">निजी स्मारकचिह्नित वस्तु सूची से हटाएँ</string>
<string name="remove_from_public_bookmarks">सार्वजनिक स्मारकचिह्नित वस्तु सूची से हटाएँ</string>
<string name="private_bookmarks">निजी स्मर्तव्य</string>
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
<string name="remove_from_public_bookmarks">सार्वजनिक स्मर्तव्य सूची से हटाएँ</string>
<string name="wallet_connect_service">धनकोष संयोजन सेवा</string>
<string name="wallet_connect_service_explainer">एक नोस्ट्र रहस्य को अधिकृत करता है ज्साप भुगतान करने के लिए क्रमक से बाहर चले बिना। रहस्य को सुरक्षित रखें तथा एक निजी पुनःप्रसारक का प्रयोग करें हो सके तो।</string>
<string name="wallet_connect_service_pubkey">धनकोष संयोजन ख्याप्यकुंचिका</string>
<string name="wallet_connect_service_relay">धनकोष संयोजन पुनःप्रसारक</string>
<string name="wallet_connect_service_secret">धनकोष संयोजन रहस्य</string>
<string name="wallet_connect_service_show_secret">गुप्त कुंचिका दिखाएँ</string>
<string name="wallet_connect_service_secret_placeholder">nsec / षोडशांकरूप निजी कुंचिका</string>
<string name="wallet_connect_service_secret_placeholder">एनसेक॰ / षोडशांकरूप निजी कुंचिका</string>
<string name="pledge_amount_in_sats">प्रतिज्ञा मात्रा साट्स में</string>
<string name="post_poll">मतदान प्रकाशित करें</string>
<string name="poll_heading_required">अनिवार्य प्रपत्रस्थान :</string>
@@ -336,8 +339,8 @@
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
<string name="file_server">अभिलेख सेवासंगणक</string>
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया NIP: जाँच करें यदि वे सक्षम हैं</string>
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (निप॰-९५)</string>
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं</string>
<string name="connect_via_tor_short">टोर / ओर्बोट स्थापन</string>
<string name="connect_via_tor">आपकी ओर्बोट स्थापना द्वारा संयोजन करें</string>
<string name="do_you_really_want_to_disable_tor_title">क्या आपके ओर्बोट / टोर से संयोजन काट दें</string>
@@ -345,16 +348,16 @@
<string name="yes">हाँ</string>
<string name="no">नहीं</string>
<string name="follow_list_selection">अनुचरण सूची</string>
<string name="follow_list_kind3follows">सभी अनुचर</string>
<string name="follow_list_kind3follows">सभी अनुचरित</string>
<string name="follow_list_global">वैश्विक</string>
<string name="follow_list_mute_list">मौन सूची</string>
<string name="connect_through_your_orbot_setup_markdown">## टोर द्वारा संयोजन करें ओर्बोट के साथ
\n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android)
\n२. ओर्बोट आरम्भ करें
\n२. ओर्बोट चलाएँ
\n३. ओर्बोट में, सोक्स॰ द्वार का जाँच करें। मूलविकल्पतः ९०५० का प्रयोग करता है।
\n४. यदि आवश्यक हो तो ओर्बोट में द्वार परिवर्तित करें
\n५. सोक्स॰ द्वार की समाकृति करें इस पटल पर
\n६. सक्रिय करें घुण्डी दबाएँ ओर्बोट को मध्यस्थ के रूप में प्रयोग करने के लिए
\n६. सक्रिय करें सूचकयुक्त घुण्डी दबाएँ ओर्बोट को प्रतिनिधि के रूप में प्रयोग करने के लिए
</string>
<string name="orbot_socks_port">ओर्बोट सोक्स॰ द्वार</string>
<string name="invalid_port_number">द्वार अंक अमान्य</string>
@@ -370,7 +373,7 @@
<string name="reply_notify">सूचित करें : </string>
<string name="channel_list_join_conversation">संवाद में जुड जाएँ</string>
<string name="channel_list_user_or_group_id">उपयोगकर्ता अथवा झुण्ड विभेदक</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent अथवा षोडशांकरूप</string>
<string name="channel_list_user_or_group_id_demo">एनपुब॰, एनइवेण्ट॰ अथवा षोडशांकरूप</string>
<string name="channel_list_create_channel">बनाएँ</string>
<string name="channel_list_join_channel">जुडें</string>
<string name="today">आज</string>
@@ -381,7 +384,7 @@
<string name="content_warning_see_warnings">विषयवस्तु चेतावनियाँ सर्वदा दिखाएँ</string>
<string name="recommended_apps">पुनःप्रशंसित : </string>
<string name="filter_spam_from_strangers">अपरिचित जन के भेजे गये कचरालेखों को छलनी द्वारा हटाएँ</string>
<string name="warn_when_posts_have_reports_from_your_follows">चेतावनी दें जब पत्र सूचित किये गये हों आपके अनुचरों द्वारा</string>
<string name="warn_when_posts_have_reports_from_your_follows">चेतावनी दें जब पत्र सूचित किये गये हों आपके द्वारा अनुचरित व्यक्तियों से</string>
<string name="new_reaction_symbol">नया प्रतिक्रिया चिह्न</string>
<string name="no_reaction_type_setup_long_press_to_change">इस उपयोगकर्ता के लिए कोई प्रतिक्रिया प्रकार पूर्व चयनित नहीं। हृदयचिह्न घुण्डी पर दीर्घतः दबाएँ परिवर्तन करने के लिए</string>
<string name="zapraiser">ज्सापोपार्जन योजना</string>
@@ -390,12 +393,14 @@
<string name="sats_to_complete">ज्सापोपार्जन योजना %1$s पर। %2$s साट्स का लक्ष्य</string>
<string name="read_from_relay">पुनःप्रसारक से पढें</string>
<string name="write_to_relay">पुनःप्रसारक को लिखकर भेजें</string>
<string name="write_to_relay_description">अष्टकों में मात्रा जो इस पुनःप्रसारक को भेजा गया था छलनियाँ तथा घटनाएँ समेत</string>
<string name="read_from_relay_description">अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत</string>
<string name="an_error_occurred_trying_to_get_relay_information">%1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ</string>
<string name="owner">अधिपति</string>
<string name="version">संस्करण</string>
<string name="software">क्रमक</string>
<string name="contact">सम्पर्क</string>
<string name="supports">सक्षम NIPs</string>
<string name="supports">अवलम्बन किए हुए निप॰ सूची</string>
<string name="admission_fees">प्रवेश शुल्क</string>
<string name="payments_url">भुगतान जालपता</string>
<string name="limitations">परिसीमाएँ</string>
@@ -403,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>
@@ -410,13 +416,13 @@
<string name="minimum_prefix">न्यूनतम उपसर्ग</string>
<string name="maximum_event_tags">अधिकतम घटना विषयसूचक</string>
<string name="content_length">विषयवस्तु लम्बाई</string>
<string name="minimum_pow">न्यूनतम PoW</string>
<string name="minimum_pow">न्यूनतम श्रमप्रमाण</string>
<string name="auth">प्रमाणीकरण</string>
<string name="payment">भुगतान</string>
<string name="cashu">काशयु लिपिखण्ड</string>
<string name="cashu">काशयु लिपिखण्ड</string>
<string name="cashu_redeem">चुकाएँ</string>
<string name="cashu_redeem_to_zap">ज्साप धनकोष को भेजें</string>
<string name="cashu_redeem_to_cashu">काशयु धनकोष में खोलें</string>
<string name="cashu_redeem_to_cashu">काशयु धनकोष में खोलें</string>
<string name="cashu_copy_token">लिपिखण्ड की अनुकृति करें</string>
<string name="no_lightning_address_set">कोई लैटनिंग पता स्थापित नहीं</string>
<string name="copied_token_to_clipboard">लिपिखण्ड की अनुकृति की गयी टाँकाफलक में</string>
@@ -429,6 +435,7 @@
<string name="are_you_sure_you_want_to_log_out">निर्गमनांकन करने पर आपकी सारी स्थानीय जानकारी मिट जाएगी। सुनिश्चित करें कि आपके निजी कुंचिकाएँ सुरक्षित रखें हैं अपनी लेखा नहीं खोना चाहते हैं तो। क्या आप आगे बढना चाहते हैं?</string>
<string name="followed_tags">अनुचरण किये हुए विषयसूचक</string>
<string name="relay_setup">पुनःप्रसारक</string>
<string name="discover_content">लेख आविष्करण</string>
<string name="discover_marketplace">पण्यक्षेत्र</string>
<string name="discover_live">तत्क्षणप्रसार</string>
<string name="discover_community">समुदाय</string>
@@ -440,9 +447,9 @@
<string name="add_sensitive_content_description">इसे दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है</string>
<string name="settings">स्थापना विकल्प</string>
<string name="connectivity_type_always">सर्वदा</string>
<string name="connectivity_type_wifi_only">केवल वैफै</string>
<string name="connectivity_type_wifi_only">केवल वैफै</string>
<string name="connectivity_type_never">कभी नहीं</string>
<string name="ui_feature_set_type_complete">सम्पन्न</string>
<string name="ui_feature_set_type_complete">सम्पूर्ण</string>
<string name="ui_feature_set_type_simplified">सरलीकृत</string>
<string name="system">यन्त्रव्यवस्था</string>
<string name="light">प्रकाशवान</string>
@@ -453,17 +460,17 @@
<string name="automatically_load_images_gifs">चित्र पूर्वीक्षण</string>
<string name="automatically_play_videos">चलचित्र का चालन</string>
<string name="automatically_show_url_preview">जालपता पूर्वीक्षण</string>
<string name="automatically_hide_nav_bars">निमग्न पृष्ठघुमाव</string>
<string name="automatically_hide_nav_bars">निमग्नकारी पृष्ठघुमाव</string>
<string name="automatically_hide_nav_bars_description">मार्गदर्शनपट्टियों को छिपाएँ पृष्ठघुमाव करने पर</string>
<string name="ui_style">प्रयोगमाध्यम शैली</string>
<string name="ui_style_description">पत्र प्रकाशन शैली का चयन करें</string>
<string name="load_image">चित्र प्राप्त करें</string>
<string name="spamming_users">कचरालेख प्रेषक</string>
<string name="muted_button">मौन किया गया। सुनने के लिए टाँकें</string>
<string name="muted_button">मौन किया गया। अमौन करने के लिए टाँकें</string>
<string name="mute_button">ध्वनि सक्रिय। मौन करने के लिए टाँकें</string>
<string name="search_button">स्थानीय तथा दूरस्थ अभिलेखों को खोजें</string>
<string name="nip05_verified">नोस्ट्र पता का सत्यापन सफल</string>
<string name="nip05_failed">नोस्ट्र पता का सत्यान असफल</string>
<string name="nip05_failed">नोस्ट्र पता का सत्यान असफल</string>
<string name="nip05_checking">नोस्ट्र पता का जाँच चल रहा है</string>
<string name="select_deselect_all">सब का चयन / अचयन करें</string>
<string name="default_relays">मूलविकल्प</string>
@@ -474,7 +481,7 @@
<string name="geohash_explainer">आपका भूगोलिक स्थान विभेदक जोडता है पत्र में। जनता जान जाएगी कि आप वर्तमान स्थान से ५ कि॰मे॰ (३ मी॰) की दूरी के अन्दर हैं</string>
<string name="add_sensitive_content_explainer">आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है</string>
<string name="new_feature_nip17_might_not_be_available_title">नयी सुविधा</string>
<string name="new_feature_nip17_might_not_be_available_description">इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा NIP-17 संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह NIP-17 नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं।</string>
<string name="new_feature_nip17_might_not_be_available_description">इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं।</string>
<string name="new_feature_nip17_activate">सक्रिय करें</string>
<string name="messages_create_public_chat">सार्वजनिक</string>
<string name="messages_create_public_private_chat_desription">नया निजी अथवा सार्वजनिक झुण्ड</string>
@@ -489,12 +496,13 @@
<string name="paste_from_clipboard">टाँकाफलक से चिपकाएँ</string>
<string name="language_description">क्रमक के प्रयोगमाध्यम के लिए</string>
<string name="theme_description">अन्धकारमय, प्रकाशवान अथवा यन्त्रव्यवस्थित प्रदर्शनशैली</string>
<string name="automatically_load_images_gifs_description">स्वचालित रूप से चित्र तथा GIFs का अवरोहण करें</string>
<string name="automatically_play_videos_description">स्वचालित रूप से चलचित्र तथा GIFs चलाता है</string>
<string name="automatically_load_images_gifs_description">स्वचालित रूप से चित्र तथा जिफ॰ का अवरोहण करें</string>
<string name="automatically_play_videos_description">स्वचालित रूप से चलचित्र तथा जिफ॰ चलाता है</string>
<string name="automatically_show_url_preview_description">जालपता के पूर्वीक्षण दिखाएँ</string>
<string name="load_image_description">चित्रों का अवरोहण कब करें</string>
<string name="copy_to_clipboard">टाँकाफलक में अनुकृति करें</string>
<string name="copy_npub_to_clipboard">टाँकाफलक में npub अनुकरण करें</string>
<string name="copy_npub_to_clipboard">टाँकाफलक में एनपुब॰ अनुकृति करें</string>
<string name="share_or_save">बाँटें अथवा अभिलेखन करें</string>
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
<string name="created_at">तब बनाया गया</string>
@@ -543,14 +551,14 @@
<string name="error_dialog_pay_invoice_error">चालान का भुगतान नहीं कर पाए</string>
<string name="error_dialog_pay_withdraw_error">निकाल नहीं पाए</string>
<string name="error_parsing_nip47_title">धनकोष संयोजन स्थापित नहीं कर पाए</string>
<string name="error_parsing_nip47">NIP-47 संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। अपक्रम : %2$s</string>
<string name="error_parsing_nip47_no_error">NIP-47 संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s।</string>
<string name="cashu_failed_redemption">काशयु नहीं चुका पाए</string>
<string name="error_parsing_nip47">निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। अपक्रम : %2$s</string>
<string name="error_parsing_nip47_no_error">निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s।</string>
<string name="cashu_failed_redemption">काशयु नहीं चुका पाए</string>
<string name="cashu_failed_redemption_explainer_error_msg">टकसाल ने यह अपक्रम संदेश उपलब्ध किया : %1$s</string>
<string name="cashu_failed_redemption_explainer_already_spent">काशयु लिपिखण्ड का व्यय हो चुका।</string>
<string name="cashu_successful_redemption">काशयु प्राप्त</string>
<string name="cashu_failed_redemption_explainer_already_spent">काशयु लिपिखण्ड का व्यय हो चुका।</string>
<string name="cashu_successful_redemption">काशयु प्राप्त</string>
<string name="cashu_successful_redemption_explainer">%1$s साट्स भेजे गये आपके धनकोष में। (शुल्क : %2$s साट्स)</string>
<string name="cashu_no_wallet_found">कोई अनुकूल काशयु धनकोष उपलब्ध नहीं यन्त्र में</string>
<string name="cashu_no_wallet_found">कोई अनुकूल काशयु धनकोष उपलब्ध नहीं यन्त्र में</string>
<string name="error_unable_to_fetch_invoice">ग्राहक के सेवा संगणक से चालान नहीं लाया जा सका</string>
<string name="wallet_connect_pay_invoice_error_error">आपका धनकोष संयोजन प्रदाता ने यह अपक्रम लौटाया : %1$s</string>
<string name="could_not_connect_to_tor">टोर से जुड नहीं पाए</string>
@@ -560,17 +568,17 @@
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct">%1$s का सुलझाव नहीं कर पाए। जाँच करें यदि आप संयोजित हैं, यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता %2$s सम्यक है</string>
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">%1$s का सुलझाव नहीं कर पाए। जाँच करें यदि आप संयोजित हैं, यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता %2$s सम्यक है।\n\nअपवर्ग था : %3$s</string>
<string name="could_not_fetch_invoice_from">%1$s से चालान नहीं लाया जा सका</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">लैटनिंग पता से JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">%1$s से JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup">लैटनिंग पता से जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">%1$s से जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration">प्रत्याह्वान जालपता उपलब्ध नहीं उपयोगकर्ता के लैटनिंग पता सेवासंगणक की समाकृति में</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">प्रत्याह्वान जालपता उपलब्ध नहीं %1$s के उत्तर से</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">लैटनिंग पता े चालान लान के JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">%1$s े चालान लान के JSON परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">लैटनिंग पता लाये गये चालान के जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">%1$s लाए गए चालान के जेसोन॰ परखनें में अपक्रम। उपयोगकर्ता की लैटनिंग स्थापना की जाँच करें</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">%2$s से चालान की मात्रा (%1$s साट्स) में दोष है। %3$s होना चाहिए था।</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। ग्राहक के लैटनिंग धनकोष ने यह अपक्रम की सूचना दी : %1$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">लैटनिंग चालान नहीं बना पाए। %1$s से संदेश : %2$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। परिणाम JSON में pr अम्श उपलब्ध नहीं।</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम JSON में pr अम्श उपलब्ध नहीं।</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">ज्साप भेजने से पहले लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
<string name="read_only_user">केवल पढनेवाला उपयोगकर्ता</string>
<string name="no_reactions_setup">कोई प्रतिक्रियाएँ स्थापित नहीं</string>
<string name="select_push_server">संयुक्तप्रेषण क्रमक का चयन करें</string>
@@ -580,7 +588,7 @@
<string name="push_server_none_explainer">प्रेषित सूचनाएँ अक्षम करता है</string>
<string name="push_server_uses_app_explainer">%1$s क्रमक का प्रयोग करता है</string>
<string name="push_server_install_app">प्रेषित सूचना स्थापन</string>
<string name="push_server_install_app_description"> प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [Nfty](https://ntfy.sh/)।
<string name="push_server_install_app_description"> प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)।
स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में।
</string>
<string name="payment_required_title">%1$s से संदेश</string>
@@ -629,7 +637,7 @@
<string name="could_not_download_from_the_server">सेवासंगणक से आरोहणकृत चित्र चलचित्र का अवरोहण नहीं कर पाए</string>
<string name="could_not_prepare_local_file_to_upload">स्थानीय अभिलेख अनुकूल नहीं बना सके आरोहण के लिए : %1$s</string>
<string name="edit_draft">पाण्डुलिपि का सम्पादन करें</string>
<string name="login_with_qr_code">क्यूआर क्रमचित्र के साथ प्रवेशांकन करें</string>
<string name="login_with_qr_code">क्यूआर क्रमचित्र के साथ प्रवेशांकन करें</string>
<string name="route">मार्ग</string>
<string name="route_home">घर</string>
<string name="route_search">खोज</string>
@@ -666,10 +674,38 @@
<string name="cancel_zap_split">ज्साप विभाजन निरस्त करें</string>
<string name="add_content_warning">विषयवस्तु चेतावनी जोडें</string>
<string name="remove_content_warning">विषयवस्तु चेतावनी हटाएँ</string>
<string name="show_npub_as_a_qr_code">क्यूआर क्रमचित्र के रूप में npub को दिखाएँ</string>
<string name="show_npub_as_a_qr_code">क्यूआर क्रमचित्र के रूप में एनपुब॰ को दिखाएँ</string>
<string name="invalid_nip19_uri">अमान्य पता</string>
<string name="invalid_nip19_uri_description">अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s</string>
<string name="zap_the_devs_title">क्रमलखकों को ज्साप करें!</string>
<string name="dm_relays_title">सी॰सं॰ आगतपेटिका पुनःप्रसारक</string>
<string name="dm_relays_through">पुनःप्रसारक : %1$s</string>
<string name="dm_relays_regular">साधारण पुनःप्रसारक प्रयुक्त</string>
<string name="dm_relays_not_found">आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें</string>
<string name="dm_relays_not_found_description">यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे।</string>
<string name="dm_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - you.nostr1.com (निजी पुनःप्रसारक - सशुल्क)</string>
<string name="dm_relays_not_found_editing">निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को सब से सन्देश स्वीकारना चाहिए पर उनका अवरोहण करने की अनुमति केवल आपको देना चाहिए।</string>
<string name="dm_relays_not_found_create_now">अभी स्थापना करें</string>
<string name="search_relays_title">खोज पुनःप्रसारक</string>
<string name="search_relays_not_found">आपके खोज पुनःप्रसारकों की स्थापना करें</string>
<string name="search_relays_not_found_description">खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा।</string>
<string name="search_relays_not_found_editing">विषयवस्तु खोजने के लिए अथवा उपयोगकर्ता सूचक जोडने में उपयोग करनें के लिए १ - ३ पुनःप्रसारकों को जोडें। सुनिश्चित करें कि आपके चयनित पुनःप्रसारक निप॰-५० को कार्यान्वित किये हैं।</string>
<string name="search_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">पुनःप्रसारक स्थापना विकल्प</string>
<string name="public_home_section">सार्वजनिक मुख्य पुनःप्रसारक</string>
<string name="public_home_section_explainer">यह पुनःप्रसारक प्रकार आपके सभी विषयवस्तु रखता है। अमेथिस्ट आपके पत्रों को प्रकाशित करने के लिए यहाँ भेजेगा तथा अन्य लोग आपके विषयवस्तु ढूँढने के लिए इनका प्रयोग करेंगे। १ - ३ पुनःप्रसारकों को जोडें। ये व्यक्तिगत अथवा सशुल्क अथवा सार्वजनिक पुनःप्रसारक हो सकते हैं।</string>
<string name="public_notif_section">सार्वजनिक आगतपेटिका पुनःप्रसारक</string>
<string name="public_notif_section_explainer">यह पुनःप्रसारक प्रकार आपके प्रकाशित पत्रों को भेजे गये सभी प्रतिवचन, टिप्पणियाँ, इष्टसूचक तथा ज्सापों को प्राप्त करता है। १ - ३ पुनःप्रसारकों को जोडें तथा सुनिश्चित करें कि वे किसी से भी पत्र स्वीकारते हैं।</string>
<string name="private_inbox_section">सी॰सं॰ आगतपेटिका पुनःप्रसारक</string>
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
<string name="private_outbox_section_explainer">१ - ३ पुनःप्रसारकों को जोडें उन घटनाओं को रखने के लिए जो कोई अन्य नहीं देख सकता जैसे कि पाण्डुलिपियाँ अथवा क्रमक के स्थापना विकल्प। आदर्शतः ये पुनःप्रसारक स्थानीय होने चाहिए अथवा प्रत्येक उपयोगकर्ता के विषयवस्तु अवरोहण के पूर्व परिचय प्रमाणिकरण अनिवार्य होना चाहिए।</string>
<string name="kind_3_section">सामान्य पुनःप्रसारक</string>
<string name="kind_3_section_description">अमेथिस्ट इन पुनःप्रसारकों का प्रयोग करता है आपके पत्रों का अवरोहण करने के लिए।</string>
<string name="search_section">खोज पुनःप्रसारक</string>
<string name="search_section_explainer">पुनःप्रसारकों की सूची जिनको किसी विषयवस्तु अथवा उपयोगकर्ताओं को खोजने में प्रयोग करना चाहिए। कोई विकल्प नहीं तो उपयोगकर्ता सूचक जोडना तथा खोज निष्क्रिय होंगे। सुनिश्चित करें कि वे निप॰-५० को कार्यान्वित किये हैं।</string>
<string name="local_section">स्थानीय पुनःप्रसारक</string>
<string name="local_section_explainer">पुनःप्रसारकों की सूची जो इस यन्त्र पर चल रहे हैं।</string>
<string name="zap_the_devs_title">क्रमलेखकों को ज्साप करें!</string>
<string name="zap_the_devs_description">आपका दान हमारा सहायक है परिवर्तन लाने में। प्रत्येक साट गणनीय है!</string>
<string name="donate_now">दान करें अभी</string>
<string name="brought_to_you_by">आप तक लाया गया इनके द्वारा :</string>
@@ -694,11 +730,11 @@
<string name="accessibility_download_for_offline">अवरोहण</string>
<string name="accessibility_lyrics_on">संगीत पद्य दिखाएँ</string>
<string name="accessibility_lyrics_off">संगीत पद्य ना दिखाएँ</string>
<string name="accessibility_turn_on_sealed_message">वृत संदेश निष्क्रिय। वृत संदेश सक्रिय करने के लिए टाँकें</string>
<string name="accessibility_turn_off_sealed_message">वृत संदेश सक्रिय। वृत संदेश निष्क्रिय करने के लिए टाँकें</string>
<string name="accessibility_turn_on_sealed_message">समावृत संदेश निष्क्रिय। समावृत संदेश सक्रिय करने के लिए टाँकें</string>
<string name="accessibility_turn_off_sealed_message">समावृत संदेश सक्रिय। समावृत संदेश निष्क्रिय करने के लिए टाँकें</string>
<string name="accessibility_send">भेजें</string>
<string name="accessibility_play_username">उपयोगकर्ता नाम को ध्वनि के रूप में चलाएँ</string>
<string name="accessibility_scan_qr_code">क्यूआर क्रमचित्र परखें</string>
<string name="accessibility_scan_qr_code">क्यूआर क्रमचित्र परखें</string>
<string name="accessibility_navigate_to_alby">तृतीय पक्ष धनकोष प्रदाता आल्बी तक जाएँ</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">एक टीका पाण्डुलिपि को उत्तर नहीं दे सकते</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">एक टीका पाण्डुलिपि पर टीका नहीं लिख सकते</string>
@@ -706,4 +742,9 @@
<string name="it_s_not_possible_to_zap_to_a_draft_note">एक टीका पाण्डुलिपि को ज्साप नहीं कर सकते</string>
<string name="draft_note">टीका पाण्डुलिपि</string>
<string name="load_from_text">संदेश से</string>
<string name="dvm_looking_for_app">क्रमक ढूँढना चालू</string>
<string name="dvm_waiting_status">कार्य का अनुरोध किया गया। उत्तर की प्रतीक्षा में</string>
<string name="dvm_requesting_job">डीवीएम॰ से कार्य का अनुरोध किया जा रहा है</string>
<string name="nwc_payment_request">भुगतान अनुरोध भेजा गया। आपके धनकोष से पुष्टीकरण की प्रतीक्षा में</string>
<string name="dvm_waiting_to_confim_payment">डीवीएम॰ से भुगतान की पुष्टीकरण अथवा परिणाम भेजने की प्रतीक्षा में</string>
</resources>

View File

@@ -15,6 +15,7 @@
<string name="group_picture">Csoport Kép</string>
<string name="explicit_content">Szókimondó tartalom</string>
<string name="spam">Spam</string>
<string name="spam_description">Ebből a csomópontból érkező spam események száma</string>
<string name="impersonation">Megszemélyesítés</string>
<string name="illegal_behavior">Illegális viselkedés</string>
<string name="other">Egyéb</string>
@@ -93,6 +94,7 @@
<string name="posts">Hozzászólások</string>
<string name="bytes">Bájtok</string>
<string name="errors">Hibák</string>
<string name="errors_description">Ebben a munkamenetben lévő kapcsolati hibák száma</string>
<string name="home_feed">Hírfolyamod</string>
<string name="private_message_feed">Privát Üzenetek listája</string>
<string name="public_chat_feed">Publikus Chat listája</string>
@@ -389,6 +391,8 @@
<string name="sats_to_complete">A ZapGyűjtés %1$s-nál. %2$s sats kell a célig</string>
<string name="read_from_relay">Olvassás a csomópontból</string>
<string name="write_to_relay">Írás a csomópontra</string>
<string name="write_to_relay_description">Ehhez a csomóponthoz küldött bájtok mennyisége, beleértve a szűrőket és az eseményeket</string>
<string name="read_from_relay_description">Ebből a csomópontból fogadott bájtok mennyisége, beleértve a szűrőket és az eseményeket</string>
<string name="an_error_occurred_trying_to_get_relay_information">Hiba történt a közvetítő információinak lekérése közben a %1$s -ról</string>
<string name="owner">Tulajdonos</string>
<string name="version">Verzió</string>
@@ -402,6 +406,7 @@
<string name="languages">Nyelvek</string>
<string name="tags">Címkék</string>
<string name="posting_policy">Szabályzat</string>
<string name="relay_error_messages">Hibák és megjegyzések ettől a csomóponttól</string>
<string name="message_length">Üzenet hossza</string>
<string name="subscriptions">Feliratkozások</string>
<string name="filters">Filterek</string>
@@ -669,9 +674,34 @@
<string name="show_npub_as_a_qr_code">Az npub megjelenítése QR-kódként</string>
<string name="invalid_nip19_uri">Érvénytelen cím</string>
<string name="invalid_nip19_uri_description">Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s</string>
<string name="dm_relays_title">PÜ Bejövő Csomópontok</string>
<string name="dm_relays_through">Csomópontok: %1$s</string>
<string name="dm_relays_regular">Általános csomópontok használata</string>
<string name="dm_relays_not_found">Állítsd be a Privát postafiókod közvetítőit</string>
<string name="dm_relays_not_found_description">Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz.</string>
<string name="dm_relays_not_found_examples">Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string>
<string name="dm_relays_not_found_editing">Adj hozzá 13 csomópontot, hogy privát postafiókodként szolgáljon. A PÜ Bejővő csomópontoknak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Te általad teszik lehetővé.</string>
<string name="dm_relays_not_found_create_now">Állítsd be most</string>
<string name="search_relays_title">Kereső Csomópontok</string>
<string name="search_relays_not_found">Állítsd be a Kereséső csomópontjaidat</string>
<string name="search_relays_not_found_description">A kifejezetten kereséshez és felhasználói címkézéshez tervezett csomópontok listájának létrehozása javítja a találati eredményeket.</string>
<string name="search_relays_not_found_editing">Adj hozzá 13 csomópontot tartalom kereséshez vagy a felhasználók címkézéséhez. Győződj meg arról, hogy a kiválasztott csomópontok alkalmazzák a NIP-50-et</string>
<string name="search_relays_not_found_examples">Jó lehetőségek a következők:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="relay_settings">Csomópont Beállítások</string>
<string name="public_home_section">Publikus Otthoni Csomópontok</string>
<string name="public_home_section_explainer">Ez a csomópont típus az összes tartalmat tárolja. Az Amethyst ide küldi a bejegyzéseidet, mások pedig ezeket a csomópontokat fogják használni, hogy a tartalmadat megtalálják. Adj hozzá 1-3 csomópontot. Lehetnek saját, fizetős vagy nyilvános csomópontok.</string>
<string name="public_notif_section">Publikus Bejövő Csomópontok</string>
<string name="public_notif_section_explainer">Ez a csomópont típus fogadja a bejegyzéseidre adott összes választ, megjegyzést, tetszésnyilvánítást és visszajelzést. Adj hozzá 13 csomópontot, és győződj meg arról, hogy ezek bárkitől fogadnak bejegyzéseket.</string>
<string name="private_inbox_section">PÜ Bejövő Csomópontok</string>
<string name="private_inbox_section_explainer">Adj hozzá 13 csomópontot, hogy privát postafiókodként szolgáljon. Mások ezeket a csomópontokat használják, hogy neked PÜ-eket küldjenek. A PÜ Bejövő csomópontoknak bárkitől kell fogadniuk bármely üzenetet, de azok letöltését csak a Te részedre teszik lehetővé. Jó lehetőségek a következők:\n - inbox.nostr.wine (fizetős)\n - you.nostr1.com (személyes csomópontok - fizetős)</string>
<string name="private_outbox_section">Privát Csomópontok</string>
<string name="private_outbox_section_explainer">Adj hozzá 13 csomópontot, hogy olyan eseményeket tároljon, amelyeket senki más nem láthat, például a piszkozataidat és/vagy az alkalmazásbeállításaidat. Ideális esetben ezek a csomópontok vagy helyiek, vagy az egyes felhasználók tartalmának letöltése előtt hitelesítést igényelnek.</string>
<string name="kind_3_section">Általános Csomópontok</string>
<string name="kind_3_section_description">Az Amethyst ezeket a csomópontokat a bejegyzések letöltésére használja.</string>
<string name="search_section">Kereső Csomópontok</string>
<string name="search_section_explainer">A tartalom vagy a felhasználók kereséséhez használt csomópontok listája. A címkézés és a keresés nem működik, ha itt nincsenek megadott lehetőségek. Győződj meg arról, hogy alkalmazzák a NIP-50-et.</string>
<string name="local_section">Helyi Csomópontok</string>
<string name="local_section_explainer">Ezen eszközön futó csomópontok listája.</string>
<string name="zap_the_devs_title">Zap a fejlesztőknek!</string>
<string name="zap_the_devs_description">Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít!</string>
<string name="donate_now">Adományozz Most</string>
@@ -709,4 +739,9 @@
<string name="it_s_not_possible_to_zap_to_a_draft_note">A bejegyzésvázlatra nem lehet zap-pelni</string>
<string name="draft_note">Bejegyzéspiszkozat</string>
<string name="load_from_text">Tőle üzenet</string>
<string name="dvm_looking_for_app">Alkalmazást keresek</string>
<string name="dvm_waiting_status">Feladat kérve, válaszra várunk</string>
<string name="dvm_requesting_job">Feladatot kér a DVM-től</string>
<string name="nwc_payment_request">Fizetési kérelem elküldve, megerősítésére vár a tárcádtól</string>
<string name="dvm_waiting_to_confim_payment">Várakozás a DVM-re a fizetés megerősítésére vagy az eredmények elküldésére</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More