Compare commits

...

113 Commits

Author SHA1 Message Date
Vitor Pamplona
76283463b0 v0.41.0 2023-05-03 18:26:52 -04:00
Vitor Pamplona
5179a821b2 Adjustments to the image upload choice to make sure they fit in a usual screen. 2023-05-03 18:25:01 -04:00
Vitor Pamplona
974a8011f9 Merge remote-tracking branch 'origin/main' 2023-05-03 17:49:14 -04:00
Vitor Pamplona
771bb3fc27 Merge pull request #391 from believethehype/nostrbuild
Added example for Image upload on nostr.build
2023-05-03 17:48:50 -04:00
Vitor Pamplona
10bd63ca2f Merge pull request #392 from Bardesss/patch-4
Update strings.xml (NL)
2023-05-03 17:44:29 -04:00
Vitor Pamplona
1fad3d1c8a Testing the removal of the image proxy server. 2023-05-03 17:20:10 -04:00
Believethehype
67cc24c4d9 added nostr.build NIP94 2023-05-03 18:13:18 +02:00
Vitor Pamplona
4368b34569 Fixes tall images bleeding into other posts. 2023-05-03 10:21:52 -04:00
Bardesss
65540c8a95 Update strings.xml (NL)
Updated translations
2023-05-03 08:13:35 +00:00
Bardesss
b801aa6bd1 Update strings.xml (NL)
Updated translation
2023-05-03 08:08:15 +00:00
Believethehype
7224ad4b05 image upload works to nostr.build 2023-05-03 00:48:01 +02:00
Believethehype
b043e2da2c Merge remote-tracking branch 'upstream/main' into nostrbuild 2023-05-03 00:27:39 +02:00
Vitor Pamplona
f8a60203ec Adds NFC support for NIP19 URIs 2023-05-02 17:59:12 -04:00
Vitor Pamplona
7f18bc3a88 0.40.6 2023-05-02 17:11:56 -04:00
Vitor Pamplona
31740d8855 Adding Post Not found on NIP95 images that are not found in any relay. 2023-05-02 17:06:32 -04:00
Vitor Pamplona
8641e0fec5 Only showing the loading url after 200 ms to avoid blinking the text. 2023-05-02 17:04:06 -04:00
Vitor Pamplona
c8f1e13448 Formatting 2023-05-02 16:47:02 -04:00
Vitor Pamplona
580cf76577 BugFix on calculating the Zap amount. 2023-05-02 16:46:52 -04:00
Vitor Pamplona
61549ebfd3 BugFix for video saving tool 2023-05-02 16:03:04 -04:00
Vitor Pamplona
797d1bb2b4 Don't try to draw local image if the file does not exist 2023-05-02 15:53:44 -04:00
Vitor Pamplona
99c16879c1 Uses resizeMode according to context. 2023-05-02 15:53:15 -04:00
Vitor Pamplona
0bf80e88ce Avoids videos getting out of the current page and leaking into the next page. 2023-05-02 15:45:32 -04:00
Vitor Pamplona
a71679ef43 Removing the exposure of the URL from the initial image and video loads. 2023-05-02 15:44:59 -04:00
Vitor Pamplona
4bbb34e8c9 v0.40.5 2023-05-02 14:38:18 -04:00
Vitor Pamplona
0e1420a626 Adjust volume based on changing global mute variable. 2023-05-02 14:38:09 -04:00
Vitor Pamplona
79782ee8e7 Fixes the volume adjusting interfering with other apps. 2023-05-02 14:32:49 -04:00
Vitor Pamplona
81b70780a6 v0.40.4 2023-05-02 12:42:38 -04:00
Vitor Pamplona
277fc5971e Moving Home feed to start first when loading the app. 2023-05-02 12:31:03 -04:00
Vitor Pamplona
850bc15f4f More visible buttons in the Video feed. 2023-05-02 12:20:05 -04:00
Vitor Pamplona
6d2db65c12 Basic Mp3 Support 2023-05-02 11:48:38 -04:00
Vitor Pamplona
89ce6dbab2 Forces search types to be used by the search components and reorganizes search 2023-05-02 11:39:29 -04:00
Vitor Pamplona
80bfbf68b4 Sending dimensions to Image files. 2023-05-02 10:02:45 -04:00
Vitor Pamplona
4464247ddb NVK is beautiful. 2023-05-02 08:20:45 -04:00
Vitor Pamplona
486d43dc1b 0.40.3 2023-05-01 20:44:37 -04:00
Vitor Pamplona
6731ca59cd BugFix for mixing NIP94 and NIP95 selections 2023-05-01 20:43:31 -04:00
Vitor Pamplona
7d620cd89a BugFix for now showing the error message when the phone can't open the file 2023-05-01 20:43:14 -04:00
Vitor Pamplona
82a2586eab Adds language to warn that NIP94 and 95 are new and other clients might not see it. 2023-05-01 20:42:48 -04:00
Vitor Pamplona
5a3f912b73 Merge pull request #389 from vivganes/translation-ta
Add tamil translations
2023-05-01 20:06:56 -04:00
Vitor Pamplona
bc54134e65 0.40.2 2023-05-01 18:54:51 -04:00
Vitor Pamplona
0f080219b2 Starts on mute 2023-05-01 18:54:06 -04:00
Vitor Pamplona
ff54629601 Autoplay controls 2023-05-01 18:53:07 -04:00
Believethehype
f889ff2547 added nostrbuild, can only upload very small files right now 2023-05-02 00:23:59 +02:00
Vitor Pamplona
8f257a7e2d - Displays Video Uploading Progress
- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
2023-05-01 16:42:58 -04:00
Vitor Pamplona
9015089593 Correctly loading NIP95 events from relays. 2023-05-01 16:39:07 -04:00
Vitor Pamplona
2b87930283 Removing the need to prune NIP95 events. 2023-05-01 16:38:35 -04:00
vivganes
15d94ca9c4 Add tamil translations 2023-05-01 21:15:55 +05:30
Vitor Pamplona
44af7f958c Merge pull request #388 from vitorpamplona/zap_payments
Zap payments without zap services on the receiving end.
2023-05-01 11:22:01 -04:00
Vitor Pamplona
2b2028853e Finishes the payment database 2023-05-01 11:12:46 -04:00
Vitor Pamplona
66dcc03351 Merge remote-tracking branch 'origin/HEAD' into zap_payments 2023-05-01 08:55:52 -04:00
Vitor Pamplona
22ba150c4e v0.40.1 2023-04-30 21:35:53 -04:00
Vitor Pamplona
63cc00976f Fixes https://github.com/vitorpamplona/amethyst/issues/387 2023-04-30 21:34:19 -04:00
Vitor Pamplona
cc9d4c237a Fixes Bug with the Nostr's Report use of double asterisk 2023-04-30 21:31:37 -04:00
Vitor Pamplona
446135ea2a Adding Zap Payments to the calculation of amounts and zapped by 2023-04-30 21:30:30 -04:00
Vitor Pamplona
a38bbd7168 Adding hex index for the Video Feed 2023-04-30 17:00:47 -04:00
Vitor Pamplona
635ed1afc1 v0.40.0 2023-04-29 19:45:27 -04:00
Vitor Pamplona
130ad40a3a Merge pull request #385 from vitorpamplona/tiktok
Tiktok
2023-04-29 19:44:36 -04:00
Vitor Pamplona
be5e58111e Merge remote-tracking branch 'origin/HEAD' 2023-04-29 19:31:12 -04:00
Vitor Pamplona
8b012884b1 0.39.0 2023-04-29 19:31:00 -04:00
Vitor Pamplona
4949a5c459 Merge pull request #384 from ZsZolee/main
Upload server Hungarian translation
2023-04-29 19:30:06 -04:00
Vitor Pamplona
ab330fea96 v0.39.0 2023-04-29 19:29:42 -04:00
Vitor Pamplona
8209c45303 New Video Feed based on NIP-94 and 95 2023-04-29 19:28:46 -04:00
Zoltan
206935289e Upload server Hungarian translation 2023-04-29 10:45:16 +02:00
Vitor Pamplona
571ff0e72b v0.38.0 2023-04-28 18:42:44 -04:00
Vitor Pamplona
940914adf5 Merge remote-tracking branch 'origin/HEAD' 2023-04-28 18:40:32 -04:00
Vitor Pamplona
1496f012a5 Adds new Highlight event kind from https://highlighter.com/ 2023-04-28 18:40:12 -04:00
Vitor Pamplona
2a552bea4d Update PRIVACY.md
Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage.
2023-04-27 19:30:20 -04:00
Vitor Pamplona
d1dc06a467 v0.37.4 2023-04-27 18:29:04 -04:00
Vitor Pamplona
f173f3194b Adjusts size of the Zap Forward text. 2023-04-27 18:27:56 -04:00
Vitor Pamplona
8ca63f32f4 Avoids flickering in the Global Feed 2023-04-27 18:20:04 -04:00
Vitor Pamplona
6b3e10ed28 Version 0.37.3 2023-04-27 17:37:23 -04:00
Vitor Pamplona
1ae785061e Reverting compose to 1.4.2 2023-04-27 17:35:53 -04:00
Vitor Pamplona
ddba4ac510 Add logs for fail to decrypt zap 2023-04-27 17:35:36 -04:00
Vitor Pamplona
0fe6bd6c83 Bugfix for clickable url 2023-04-27 17:13:56 -04:00
Vitor Pamplona
925289ae64 Reducing the amount of filters to send to relays to 10 2023-04-27 17:13:45 -04:00
Vitor Pamplona
f6f82a16f5 v0.37.2 2023-04-27 16:23:38 -04:00
Vitor Pamplona
54511dcc69 Updating dependencies 2023-04-27 16:23:30 -04:00
Vitor Pamplona
2f457d135f Improving translations 2023-04-27 15:54:26 -04:00
Vitor Pamplona
362082d608 Correcly copy pasting NIP94 and NIP95 addresses. 2023-04-27 15:40:28 -04:00
Vitor Pamplona
78cfa98456 Merge pull request #381 from vivganes/non-english-hashtags
Add support for non-english hashtags too
2023-04-27 15:28:38 -04:00
Vitor Pamplona
5e37a97d1e Merge branch 'main' into non-english-hashtags 2023-04-27 15:28:08 -04:00
Vitor Pamplona
561189144e v0.37.1 2023-04-27 15:06:04 -04:00
Vitor Pamplona
285a61e38f Enabling non NIP94 uploads for the PlayStore 2023-04-27 15:01:58 -04:00
Vitor Pamplona
2ca877b3c3 Zap Forwarding 2023-04-27 14:43:28 -04:00
vivganes
f105188738 Add support for non-english hashtags too 2023-04-27 21:13:25 +05:30
Vitor Pamplona
0a11de1892 Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files 2023-04-27 09:27:29 -04:00
Vitor Pamplona
1596a7b559 Pruning file storage events when pausing the app. 2023-04-27 09:26:26 -04:00
Vitor Pamplona
d4a3ee2651 Adding nip95 to readme 2023-04-26 19:04:01 -04:00
Vitor Pamplona
c60ad472e8 v0.37.0 2023-04-26 18:05:19 -04:00
Vitor Pamplona
04e4c4692f disabling logs 2023-04-26 18:05:02 -04:00
Vitor Pamplona
ab747f5e93 Activating NIP-95 support 2023-04-26 18:04:38 -04:00
Vitor Pamplona
e370e75ba4 Offers NostrImg as a choice of image server 2023-04-26 16:36:52 -04:00
Vitor Pamplona
a669e37774 Adds server selection support 2023-04-26 14:23:06 -04:00
Vitor Pamplona
86fe9b4a65 Adds support for NIP-95 Images 2023-04-26 14:22:49 -04:00
Vitor Pamplona
c6a0b0950a Fixes AUTH NIP42 bug. Amethyst was sending the event as a regular EVENT and not an AUTH command 2023-04-26 13:51:22 -04:00
Vitor Pamplona
f03cd04566 Merge pull request #380 from ZsZolee/main
Zap Type Hungarian translation
2023-04-26 13:02:01 -04:00
Zoltan
3175d40482 Zap Type Hungarian translation 2023-04-26 11:08:13 +02:00
Vitor Pamplona
24d722cdb7 v0.36.0 2023-04-25 21:19:32 -04:00
Vitor Pamplona
14bc1fb7d4 Support for NIP-42 2023-04-25 21:18:33 -04:00
Vitor Pamplona
90955515d0 Updating readme with the new features. 2023-04-25 18:26:18 -04:00
Vitor Pamplona
a22ec35a26 v0.35.1 2023-04-25 18:16:51 -04:00
Vitor Pamplona
f38f5737b0 Opens image dialog in the page of the current selection. 2023-04-25 18:11:08 -04:00
Vitor Pamplona
e958515dad Improves configuration of Zap types (double click and click and hold) 2023-04-25 18:06:00 -04:00
Vitor Pamplona
add6b06446 Marks all Zaps to be private by default. 2023-04-25 17:13:07 -04:00
Vitor Pamplona
d070712f2b Activates gif preview in Notifications 2023-04-25 17:12:48 -04:00
Vitor Pamplona
b9f58b3701 Correctly updating the screen after zaps have been accounted for 2023-04-25 17:02:44 -04:00
Vitor Pamplona
91f3f036ae Making poll votes work with Private Zaps. 2023-04-25 16:35:42 -04:00
Vitor Pamplona
4ef90e26cf Merge pull request #377 from ZsZolee/main
New Hungarian translation
2023-04-25 14:27:13 -04:00
Vitor Pamplona
f7182d6393 Preview for quote noting. 2023-04-25 13:37:39 -04:00
Vitor Pamplona
99861facc9 Improving the performance and UI of Zap notification messages. 2023-04-25 09:21:12 -04:00
Vitor Pamplona
a5728daeb0 Adds FileHeader loading to a thread. 2023-04-25 08:20:51 -04:00
Zoltan
a4ac2eeb89 New Hungarian translation 2023-04-25 12:07:01 +02:00
Vitor Pamplona
a5a320b889 Merge pull request #376 from kiwiidb/main
fix bug in nip47 scheme check
2023-04-25 05:08:13 -04:00
kiwiidb
8f927c29e3 fix bug in nip47 scheme check 2023-04-25 10:15:07 +02:00
89 changed files with 4628 additions and 927 deletions

View File

@@ -1,12 +1,12 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.

View File

@@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
@@ -41,13 +41,25 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
- [x] Bounty support (nostrbounties.com)
- [x] De-googled F-Droid flavor
- [x] External Identity Support (NIP-39)
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
@@ -59,7 +71,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] Polls (NIP-69)
# Development Overview

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 124
versionName "0.35.0"
versionCode 141
versionName "0.41.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -86,7 +86,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation 'androidx.activity:activity-compose:1.7.0'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -134,7 +134,7 @@ dependencies {
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
@@ -177,7 +177,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"

View File

@@ -0,0 +1,91 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
@Test()
fun testImgurUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
ImageUploader.uploadImage(
inputStream,
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
}
@Test()
@Ignore
fun testNostrBuildUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
ImageUploader.uploadImage(
inputStream,
"image/gif",
NostrBuildServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
}
@Test()
fun testNostrImgUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
ImageUploader.uploadImage(
inputStream,
"image/gif",
NostrImgServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
}
}

View File

@@ -0,0 +1,139 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
import fr.acinq.secp256k1.Hex
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PrivateZapTests {
@Test
fun testPollZap() {
val poll = Event.Companion.fromJson(
"""{
"content": "New poll \n\n #zappoll",
"created_at": 1682440713,
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
"kind": 6969,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
"tags": [
[
"poll_option",
"0",
"Test 1"
],
[
"poll_option",
"1",
"Test 2"
],
[
"value_maximum",
"null"
],
[
"value_minimum",
"null"
],
[
"consensus_threshold",
"null"
],
[
"closed_at",
"null"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
poll,
setOf("wss://relay.damus.io/"),
loggedIn,
0,
"",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
@Test
fun testKind1PrivateZap() {
val textNote = Event.Companion.fromJson(
"""{
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"created_at": 1682369982,
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
"kind": 1,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
"tags": [
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
textNote,
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
loggedIn,
null,
"test",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
}

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -46,6 +47,12 @@
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />

View File

@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
@@ -44,6 +45,7 @@ private object PrefKeys {
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -194,6 +196,7 @@ object LocalPreferences {
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -225,6 +228,11 @@ object LocalPreferences {
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
@@ -269,6 +277,7 @@ object LocalPreferences {
translateTo,
zapAmountChoices,
defaultZapType,
defaultFileServer,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,

View File

@@ -35,8 +35,8 @@ object ServiceManager {
NostrChatroomListDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import android.util.Log
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
@@ -11,6 +12,7 @@ import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -18,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
import java.math.BigDecimal
import java.util.Locale
val DefaultChannels = setOf(
@@ -44,7 +48,8 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -167,7 +172,32 @@ class Account(
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
return (pubKey == pubkeyHex)
}
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
val myNip47 = zapPaymentRequest ?: return null
return zapResponseEvent.response(
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
myNip47.pubKeyHex.toByteArray()
)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return zappedNote.isZappedBy(userProfile(), this) == true
}
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
return zappedNote?.zappedAmount(
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
zapPaymentRequest?.pubKeyHex?.toByteArray()
) ?: BigDecimal.ZERO
}
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return
zapPaymentRequest?.let { nip47 ->
@@ -176,11 +206,11 @@ class Account(
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start()
LocalCache.consume(event) {
LocalCache.consume(event, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
if (privKey != null) {
onResponse(it.response(privKey, event.pubKey.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
}
}
@@ -373,6 +403,41 @@ class Account(
}
}
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null
val data = FileStorageEvent.create(
mimeType = headerInfo.mimeType ?: "",
data = data,
privateKey = loggedIn.privKey!!
)
val signedEvent = FileStorageHeaderEvent.create(
data,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
return Pair(data, signedEvent)
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
if (!isWriteable()) return null
Client.send(data)
LocalCache.consume(data, null)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendHeader(headerInfo: FileHeader): Note? {
if (!isWriteable()) return null
@@ -381,18 +446,19 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: String? = null) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -405,6 +471,7 @@ class Account(
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!
)
@@ -420,7 +487,8 @@ class Account(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
closedAt: Int?,
zapReceiver: String? = null
) {
if (!isWriteable()) return
@@ -438,14 +506,15 @@ class Account(
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt
closedAt = closedAt,
zapReceiver = zapReceiver
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -457,13 +526,14 @@ class Account(
channel = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -476,6 +546,7 @@ class Account(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -569,6 +640,12 @@ class Account(
LocalCache.consume(event)
}
fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? {
if (!isWriteable()) return null
return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!)
}
fun removePublicBookmark(note: Note) {
if (!isWriteable()) return
@@ -646,6 +723,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultFileServer(server: ServersAvailable) {
defaultFileServer = server
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -691,11 +774,61 @@ class Account(
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
event?.content()
}
}
fun decryptZapContentAuthor(note: Note): Event? {
val event = note.event
val loggedInPrivateKey = loggedIn.privKey
return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) {
val recipientPK = event.zappedAuthor().firstOrNull()
val recipientPost = event.zappedPost().firstOrNull()
if (recipientPK == userProfile().pubkeyHex) {
// if the receiver is logged in, these are the params.
val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
val altPrivateKeyToUse = if (recipientPost != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPost,
event.createdAt
)
} else if (recipientPK != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPK,
event.createdAt
)
} else {
null
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
}
result
} else {
null
}
}
} else {
null
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
liveLanguages.invalidateData()

View File

@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -12,6 +13,9 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -31,7 +35,8 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -379,6 +384,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -653,29 +659,130 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
fun consume(event: FileHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileStorageHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: HighlightEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
// Adds to user profile
author.addNote(note)
refreshObservers(note)
}
fun consume(event: FileStorageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// this is an invalid event. But we don't need to keep the data in memory.
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
note.loadEvent(eventNoData, author, emptyList())
refreshObservers(note)
}
fun consume(event: LnZapPaymentRequestEvent) {
// Does nothing without a response callback.
}
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
awaitingPaymentRequests.put(event.id, onResponse)
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
zappedNote?.addZapPayment(note, null)
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
refreshObservers(note)
}
fun consume(event: LnZapPaymentResponseEvent) {
val responseCallback = awaitingPaymentRequests[event.requestId()]
val requestId = event.requestId()
val pair = awaitingPaymentRequests[requestId] ?: return
val (zappedNote, responseCallback) = pair
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
requestNote?.let { request ->
zappedNote?.addZapPayment(request, note)
}
if (responseCallback != null) {
responseCallback(event)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
@@ -156,6 +159,17 @@ open class Note(val idHex: String) {
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -174,6 +188,17 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -200,9 +225,19 @@ open class Note(val idHex: String) {
}
}
fun isZappedBy(user: User): Boolean {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author === user }
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent)
} else {
null
}
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
}
}
fun isReactedBy(user: User): Boolean {
@@ -231,12 +266,44 @@ open class Note(val idHex: String) {
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
.filter { it.amount != null }
.associate {
it.lnInvoice() to it.amount
}
.toMap()
val completedPayments = if (privKey != null && walletServicePubkey != null) {
// Payments confirmed by the User's Wallet
zapPayments
.asSequence()
.filter {
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null
} else {
LnInvoiceUtil.getAmountInSats(lnInvoice)
}
} catch (e: java.lang.Exception) {
null
}
lnInvoice to amount
}
.toMap()
} else {
emptyMap()
}
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {

View File

@@ -14,6 +14,7 @@ class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
) {
@@ -21,15 +22,32 @@ class FileHeader(
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
}
}
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try {
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(imageData).toHexKey()
val size = imageData.size
val hash = sha256.digest(data).toHexKey()
val size = data.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
@@ -42,20 +60,22 @@ class FileHeader(
mBitmap.height
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else {
null
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@@ -14,7 +14,7 @@ object Nip47 {
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") {
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}

View File

@@ -14,8 +14,10 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrAccountDataSource : NostrDataSource("AccountData") {
@@ -113,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountBookmarkListFilter()
).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
Client.send(
event,
relay.url
)
}
}
}
}

View File

@@ -76,7 +76,10 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event, relay)
is FileStorageEvent -> LocalCache.consume(event, relay)
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
is HighlightEvent -> LocalCache.consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
@@ -126,6 +129,10 @@ abstract class NostrDataSource(val debugName: String) {
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge)
}
}
init {
@@ -221,4 +228,5 @@ abstract class NostrDataSource(val debugName: String) {
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
@@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
@@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)

View File

@@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind
PollNoteEvent.kind,
HighlightEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -116,7 +117,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, FileHeaderEvent.kind
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

View File

@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }

View File

@@ -20,7 +20,7 @@ class ChannelMessageEvent(
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -32,6 +32,9 @@ class ChannelMessageEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)

View File

@@ -19,7 +19,7 @@ open class Event(
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
var content: String,
val content: String,
val sig: HexKey
) : EventInterface {
override fun id(): HexKey = id
@@ -41,6 +41,10 @@ open class Event(
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
@@ -203,6 +207,8 @@ open class Event(
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -223,6 +229,9 @@ open class Event(
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -33,4 +33,6 @@ interface EventInterface {
fun getReward(): BigDecimal?
fun getPoWRank(): Int
fun zapAddress(): String?
}

View File

@@ -19,6 +19,7 @@ class FileHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@@ -30,6 +31,7 @@ class FileHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@@ -41,6 +43,7 @@ class FileHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@@ -48,11 +51,12 @@ class FileHeaderEvent(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
var tags = listOfNotNull(
val tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },

View File

@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
class FileStorageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
fun decode(): ByteArray? {
return try {
Base64.getDecoder().decode(content)
} catch (e: Exception) {
Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
null
}
}
companion object {
const val kind = 1064
private const val TYPE = "type"
private const val DECRYPT = "decrypt"
fun encode(bytes: ByteArray): String {
return Base64.getEncoder().encodeToString(bytes)
}
fun create(
mimeType: String,
data: ByteArray,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileStorageEvent {
val tags = listOfNotNull(
listOf(TYPE, mimeType)
)
val content = encode(data)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class FileStorageHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1065
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
storageEvent: FileStorageEvent,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,36 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class HighlightEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun inUrl() = taggedUrls().firstOrNull()
fun author() = taggedUsers().firstOrNull()
fun quote() = content
companion object {
const val kind = 9802
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -57,7 +57,7 @@ class LnZapEvent(
return content
}
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)

View File

@@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
class LnZapPaymentRequestEvent(
@@ -18,11 +23,15 @@ class LnZapPaymentRequestEvent(
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray): String? {
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
return Utils.decrypt(content, sharedSecret)
val jsonText = Utils.decrypt(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -39,7 +48,7 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
serializedRequest,
@@ -59,11 +68,37 @@ class LnZapPaymentRequestEvent(
// REQUEST OBJECTS
abstract class Request(val method: String, val params: Params)
abstract class Params
abstract class Request(var method: String? = null)
// PayInvoice Call
class PayInvoiceParams(var invoice: String? = null)
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
class PayInvoiceParams(val invoice: String) : Params()
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
companion object {
fun create(bolt11: String): PayInvoiceMethod {
return PayInvoiceMethod(PayInvoiceParams(bolt11))
}
}
}
class RequestDeserializer :
JsonDeserializer<Request?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Request? {
val jsonObject = json.asJsonObject
val method = jsonObject.get("method")?.asString
if (method == "pay_invoice") {
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
}
return null
}
companion object {
}
}

View File

@@ -1,9 +1,14 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
class LnZapPaymentResponseEvent(
id: HexKey,
@@ -36,7 +41,8 @@ class LnZapPaymentResponseEvent(
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, Response::class.java)
} else {
null
}
@@ -58,7 +64,7 @@ abstract class Response(
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
@@ -86,3 +92,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
OTHER // Other error.
}
}
class ResponseDeserializer :
JsonDeserializer<Response?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Response? {
val jsonObject = json.asJsonObject
val resultType = jsonObject.get("result_type")?.asString
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")?.asJsonObject
val error = jsonObject.get("error")?.asJsonObject
if (result != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
}
return null
}
companion object {
}
}

View File

@@ -24,6 +24,8 @@ class LnZapRequestEvent(
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
companion object {
const val kind = 9734
@@ -135,26 +137,27 @@ class LnZapRequestEvent(
if (parts.size != 2) {
throw IllegalArgumentException("Invalid message format")
}
val iv = parts[1].run { Bech32.decode(this) }
val encryptedMsg = parts.first().run { Bech32.decode(this) }
val iv = parts[1].run { Bech32.decode(this).second }
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0)))
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
try {
val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0))
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
return String(decryptedMsgBytes)
} catch (ex: BadPaddingException) {
throw IllegalArgumentException("Bad padding")
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? {
val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" }
if (anonTag != null && anonTag.size > 1) {
val encnote = anonTag?.elementAt(1)
if (encnote != null && encnote != "") {
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray())
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent

View File

@@ -48,7 +48,8 @@ class PollNoteEvent(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
closedAt: Int?,
zapReceiver: String?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -68,6 +69,11 @@ class PollNoteEvent(
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString()))
if (zapReceiver != null) {
tags.add(listOf("zap", zapReceiver))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -58,6 +58,7 @@ class PrivateDmEvent(
msg: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
@@ -79,6 +80,9 @@ class PrivateDmEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class RelayAuthEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
companion object {
const val kind = 22242
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("relay", relay),
listOf("challenge", challenge)
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -26,6 +26,7 @@ class TextNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -46,9 +47,13 @@ class TextNoteEvent(
extraTags?.forEach {
tags.add(listOf("t", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -160,6 +160,14 @@ object Client : RelayPool.Listener {
}
}
override fun onAuth(relay: Relay, challenge: String) {
// 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.onAuth(relay, challenge) }
}
}
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -196,5 +204,7 @@ object Client : RelayPool.Listener {
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit
}
}

View File

@@ -5,6 +5,7 @@ import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -95,8 +96,6 @@ class Relay(
val type = msg[0].asString
val channel = msg[1].asString
// Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}")
when (type) {
"EVENT" -> {
val event = Event.fromJson(msg[2], Client.lenient)
@@ -115,13 +114,17 @@ class Relay(
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
// Log.w("Relay", "Relay onNotice $url, $channel")
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
// Log.w("Relay", "Relay on OK $url, $channel")
Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msg[1].asString)
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
@@ -202,7 +205,7 @@ class Relay(
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]"""
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
@@ -230,11 +233,19 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (signedEvent is RelayAuthEvent) {
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
}
}
fun close(subscriptionId: String) {
@@ -272,6 +283,8 @@ class Relay(
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
fun onAuth(relay: Relay, challenge: String)
/**
* Connected to or disconnected from a relay
*

View File

@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onAuth(relay: Relay, challenge: String)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
override fun onAuth(relay: Relay, challenge: String) {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)

View File

@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {

View File

@@ -7,13 +7,17 @@ import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.util.UUID
object ImageSaver {
/**
@@ -74,6 +78,38 @@ object ImageSaver {
})
}
fun saveImage(
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?
) {
try {
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
displayName = UUID.randomUUID().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver
)
} else {
saveContentDefault(
fileName = UUID.randomUUID().toString() + ".$extension",
contentSource = buffer,
context = context
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
@@ -90,8 +126,14 @@ object ImageSaver {
)
}
val masterUri = if (contentType.startsWith("image")) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}

View File

@@ -1,65 +1,122 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.*
import java.io.InputStream
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
fun uploadImage(
uri: Uri,
server: ServersAvailable,
context: Context,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = if (server == ServersAvailable.IMGUR) {
ImgurServer()
} else if (server == ServersAvailable.NOSTRIMG) {
NostrImgServer()
} else if (server == ServersAvailable.NOSTR_BUILD) {
NostrBuildServer()
} else {
ImgurServer()
}
val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri
if (file != null) {
uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
}
}
fun uploadImage(
file: File,
inputStream: InputStream,
contentType: String?,
server: FileServer,
serverType: ServersAvailable,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = OkHttpClient.Builder().build()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
if (serverType == ServersAvailable.NOSTR_BUILD) {
requestBuilder.addHeader("Content-Type", "multipart/form-data")
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"fileToUpload",
"$fileName.$extension",
file.asRequestBody(contentType?.toMediaType())
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
)
.build()
} else {
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"$fileName.$extension",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
imageInputStream.source().use(sink::writeAll)
}
}
)
.build()
)
.build()
}
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
server.clientID()?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
.build()
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
val url = server.parseUrlFromSucess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
@@ -79,3 +136,79 @@ object ImageUploader {
})
}
}
fun getRealPathFromURI(uri: Uri, context: Context): String? {
val returnCursor = context.contentResolver.query(uri, null, null, null, null)
val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
returnCursor.moveToFirst()
val name = returnCursor.getString(nameIndex)
val file = File(context.filesDir, name)
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(file)
var read = 0
val maxBufferSize = 1 * 1024 * 1024
val bytesAvailable: Int = inputStream?.available() ?: 0
val bufferSize = Math.min(bytesAvailable, maxBufferSize)
val buffers = ByteArray(bufferSize)
while (inputStream?.read(buffers).also {
if (it != null) {
read = it
}
} != -1
) {
outputStream.write(buffers, 0, read)
}
Log.e("File Size", "Size " + file.length())
inputStream?.close()
outputStream.close()
Log.e("File Path", "Path " + file.path)
} catch (e: java.lang.Exception) {
Log.e("Exception", e.message!!)
}
return file.path
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSucess(body: String): String?
open fun clientID(): String? = null
}
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSucess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun clientID() = null
}
class ImgurServer : FileServer() {
override fun postUrl(contentType: String?): String {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSucess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun clientID() = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun parseUrlFromSucess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
return url.toString().replace("\"", "")
}
override fun clientID() = null
}

View File

@@ -0,0 +1,215 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
open fun load(account: Account, uri: Uri, contentType: String?) {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = defaultServer()
if (selectedServer == ServersAvailable.IMGUR) {
selectedServer = ServersAvailable.IMGUR_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
}
}
fun upload(context: Context) {
isUploadingImage = true
val contentResolver = context.contentResolver
val uri = galleryUri ?: return
val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
context = context,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
open fun cancel() {
galleryUri = null
isUploadingImage = false
mediaType = null
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = ""
selectedServer = account?.defaultFileServer
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(15000)
}
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
try {
val imageData = URL(imageUrl).readBytes()
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
imageData,
imageUrl,
mimeType,
description,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
val note = account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second)
}
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
uploadingDescription.value = null
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}
}

View File

@@ -0,0 +1,224 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
val scroolState = rememberScrollState()
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
.fillMaxHeight().imePadding()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
PostButton(
onPost = {
onClose()
postViewModel.upload(context)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
},
isActive = postViewModel.canPost()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel, account)
}
}
}
}
}
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val resolver = LocalContext.current.contentResolver
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
if (postViewModel.isImage() == true) {
AsyncImage(
model = postViewModel.galleryUri.toString(),
contentDescription = postViewModel.galleryUri.toString(),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = postViewModel.galleryUri) {
scope.launch(Dispatchers.IO) {
postViewModel.galleryUri?.let {
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
}
}
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
}
}
}

View File

@@ -17,8 +17,12 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
@@ -204,15 +209,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val url = postViewModel.contentToAddUrl
if (url != null) {
ImageVideoDescription(
url,
onAdd = { description ->
postViewModel.upload(url, description, context)
},
onCancel = {
postViewModel.contentToAddUrl = null
}
)
Row(verticalAlignment = Alignment.CenterVertically) {
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server ->
postViewModel.upload(url, description, server, context)
account.changeDefaultFileServer(server)
},
onCancel = {
postViewModel.contentToAddUrl = null
}
)
}
}
val user = postViewModel.account?.userProfile()
@@ -298,11 +307,16 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
Row(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp)
modifier = Modifier
) {
postViewModel.selectImage(it)
}
@@ -320,6 +334,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
}
}
@@ -383,6 +401,87 @@ private fun AddLnInvoiceButton(
}
}
@Composable
private fun ForwardZapTo(
postViewModel: NewPostViewModel,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
if (!postViewModel.wantsForwardZapTo) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
imageVector = Icons.Default.ArrowForwardIos,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange
)
}
}
}
if (postViewModel.wantsForwardZapTo) {
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(0.dp),
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
Button(
@@ -485,10 +584,21 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
}
enum class ServersAvailable {
IMGUR,
NOSTR_BUILD,
NOSTRIMG,
IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NIP95
}
@Composable
fun ImageVideoDescription(
uri: Uri,
onAdd: (String) -> Unit,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit,
onCancel: () -> Unit
) {
val resolver = LocalContext.current.contentResolver
@@ -498,6 +608,22 @@ fun ImageVideoDescription(
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
val fileServers = listOf(
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -537,6 +663,7 @@ fun ImageVideoDescription(
modifier = Modifier
.padding(start = 10.dp)
.weight(1.0f)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
IconButton(
@@ -561,6 +688,7 @@ fun ImageVideoDescription(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
if (mediaType.startsWith("image")) {
AsyncImage(
@@ -570,6 +698,7 @@ fun ImageVideoDescription(
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
@@ -595,30 +724,61 @@ fun ImageVideoDescription(
}
}
var message by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
selectedServer = fileServers[it].first
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
)
}
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
}
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
onAdd(message)
onAdd(message, selectedServer)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(

View File

@@ -37,6 +37,7 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: Boolean? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
@@ -61,6 +62,11 @@ open class NewPostViewModel : ViewModel() {
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<User?>(null)
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -82,13 +88,18 @@ open class NewPostViewModel : ViewModel() {
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}")
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
contentToAddUrl = null
wantsForwardZapTo = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
this.account = account
}
@@ -96,36 +107,63 @@ open class NewPostViewModel : ViewModel() {
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
tagger.run()
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions)
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
} else {
forwardZapToEditting.text
}
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions)
null
}
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
}
cancel()
}
fun upload(it: Uri, description: String, context: Context) {
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
isUploadingImage = true
contentToAddUrl = null
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP97Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
val contentResolver = context.contentResolver
if (server == ServersAvailable.NIP95) {
val contentType = contentResolver.getType(it)
contentResolver.openInputStream(it)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
)
} else {
ImageUploader.uploadImage(
uri = it,
server = server,
context = context,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) {
createNIP94Record(imageUrl, mimeType, description)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
open fun cancel() {
@@ -144,6 +182,14 @@ open class NewPostViewModel : ViewModel() {
closedAt = null
wantsInvoice = false
wantsForwardZapTo = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
}
open fun findUrlInMessage(): String? {
@@ -165,6 +211,21 @@ open class NewPostViewModel : ViewModel() {
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
} else {
userSuggestions = emptyList()
}
}
}
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
} else {
@@ -175,15 +236,29 @@ open class NewPostViewModel : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
if (userSuggestionsMainMessage == true) {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else {
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
forwardZapTo = item
forwardZapToEditting = TextFieldValue(
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
}
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
userSuggestionAnchor = null
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
}
@@ -210,7 +285,7 @@ open class NewPostViewModel : ViewModel() {
}
}
fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
@@ -247,6 +322,35 @@ open class NewPostViewModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
isUploadingImage = false
note?.let {
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
}

View File

@@ -170,6 +170,8 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
server = account.defaultFileServer,
context = context,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)

View File

@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch
import java.io.File
/**
* A button to save the remote image to the gallery.
@@ -83,3 +84,63 @@ fun SaveToGallery(url: String) {
Text(text = stringResource(id = R.string.save), color = Color.White)
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(localFile: File, mimeType: String?) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
localFile = localFile,
mimeType = mimeType,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = stringResource(id = R.string.save), color = Color.White)
}
}

View File

@@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) {
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
}
}

View File

@@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
ClickableText(
text = AnnotatedString(urlText),
onClick = { runCatching { uri.openUri(url) } },
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -51,7 +51,7 @@ import java.net.URL
import java.util.regex.Pattern
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
@@ -59,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -86,9 +86,9 @@ fun RichTextViewer(
navController: NavController
) {
Column(modifier = modifier) {
if (content.startsWith("# ") ||
if (content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {
@@ -142,9 +142,9 @@ fun RichTextViewer(
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableVideo(fullUrl)
ZoomableUrlVideo(fullUrl)
} else {
null
}

View File

@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -26,10 +29,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
@@ -62,7 +66,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
@@ -72,7 +76,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -81,7 +85,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
@@ -89,7 +92,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
Column() {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = 14.sp)
}
}
}
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)

View File

@@ -1,17 +1,49 @@
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
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.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
@@ -23,6 +55,14 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
import java.io.File
public var muted = mutableStateOf(true)
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
@@ -48,35 +88,63 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
volume = 0f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
)
}
prepare()
}
}
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
exoPlayer.play()
} else {
exoPlayer.pause()
innerOnDialog(it)
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
}
)
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -95,3 +163,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
}
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
val view = LocalView.current
var isVisible: Boolean? by remember { mutableStateOf(null) }
LaunchedEffect(isVisible) {
onVisibilityChanges(isVisible == true)
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
}
}
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = android.graphics.Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return false
}
val bounds = boundsInWindow()
// Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
}
}
}

View File

@@ -3,14 +3,20 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -52,6 +58,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -59,37 +66,81 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05
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
import java.io.File
import java.security.MessageDigest
abstract class ZoomableContent(
val url: String,
val description: String? = null,
val hash: String? = null
val dim: String? = null
)
class ZoomableImage(
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
dim: String? = null,
val uri: String? = null
) : ZoomableContent(description, dim)
class ZoomableUrlImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null
) : ZoomableContent(url, description, hash)
val blurhash: String? = null,
dim: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
class ZoomableVideo(
class ZoomableUrlVideo(
url: String,
description: String? = null,
hash: String? = null
) : ZoomableContent(url, description, hash)
hash: String? = null,
dim: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent(
val localFile: File,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: String? = null,
val uri: String
) : ZoomableContent(description, dim)
class ZoomableLocalImage(
localFile: File,
mimeType: String? = null,
description: String? = null,
val blurhash: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
class ZoomableLocalVideo(
localFile: File,
mimeType: String? = null,
description: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@@ -97,11 +148,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
} else if (isVideo) {
ZoomableVideo(fullUrl)
ZoomableUrlVideo(fullUrl)
} else {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
}
}
@@ -109,14 +160,115 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
)
} else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
)
} else {
mainImageModifier = mainImageModifier.clickable {
dialogOpen = true
}
}
when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
}
}
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
mainImageModifier: Modifier
) {
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
val ratio = aspectRatio(content.dim)
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
if (ratio != null) {
it.aspectRatio(ratio, maxHeight.isFinite)
}
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
if (content.localFile.exists()) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
}
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
BlankNote()
}
}
}
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
@@ -126,6 +278,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
mutableStateOf<Boolean?>(null)
}
val ratio = aspectRatio(content.dim)
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
@@ -134,58 +288,86 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
val mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
)
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
if (ratio != null) {
it.aspectRatio(ratio, maxHeight.isFinite)
}
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else {
VideoView(content.url, content.description) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
if (imageState is AsyncImagePainter.State.Error) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
}
private fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
width / height
} catch (e: Exception) {
null
}
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
delay(200)
cnt = content
}
}
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
Text("Loading content... ")
}
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
@@ -219,20 +401,22 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
}
@Composable
private fun DisplayBlueHash(
content: ZoomableImage,
private fun DisplayBlurHash(
blurhash: String?,
description: String?,
contentScale: ContentScale,
modifier: Modifier
) {
if (content.bluehash == null) return
if (blurhash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
blurhash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
contentDescription = description,
contentScale = contentScale,
modifier = modifier
)
}
@@ -248,6 +432,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
Column() {
val pagerState: PagerState = remember { PagerState() }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
if (page > -1) {
pagerState.scrollToPage(page)
}
}
Row(
modifier = Modifier
.padding(10.dp)
@@ -257,7 +448,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url)
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
if (allImages.size > 1) {
@@ -277,58 +473,28 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
fun RenderImageOrVideo(content: ZoomableContent) {
val mainModifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
if (content is ZoomableUrlImage) {
UrlImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
}
} else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
}
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->

View File

@@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -3,6 +3,7 @@ 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.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&

View File

@@ -0,0 +1,40 @@
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.model.*
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
return collection
.asSequence()
.filter {
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Search,
Route.Notification
)

View File

@@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -58,10 +61,31 @@ fun AppNavigation(
GlobalFeedFilter.account = account
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
VideoFeedFilter.account = account
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
navController = navController,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
}
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false

View File

@@ -41,6 +41,12 @@ sealed class Route(
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
)
object Video : Route(
route = "Video?scrollToTop={scrollToTop}",
icon = R.drawable.ic_video,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
)
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,

View File

@@ -113,6 +113,8 @@ fun ChatroomMessageCompose(
var alignment: Arrangement.Horizontal
var shape: Shape
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
@@ -333,11 +335,11 @@ fun ChatroomMessageCompose(
}
Row() {
LikeReaction(baseNote, accountViewModel)
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, accountViewModel)
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, accountViewModel, showCounter = false) {
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
@@ -36,10 +37,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -86,9 +84,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.combinedClickable(
onClick = {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
@@ -140,16 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
for (i in multiSetCard.zapEvents) {
var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let {
LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!)
}
if (decryptedContent != null) {
(i.key.event as Event).content = decryptedContent.content
i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
}
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap")
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
}
}
@@ -222,12 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
fun AuthorGalleryZaps(
authorNotes: Map<Note, Note>,
navController: NavController,
account: Account,
accountViewModel: AccountViewModel,
kind: String = "nonzap"
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
@@ -235,38 +225,94 @@ fun AuthorGallery(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
if (it.event?.content() != "" && kind == "zap") {
Row(Modifier.fillMaxWidth()) {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
} else {
Row() {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
}
}
}
}
@Composable
private fun AuthorPictureAndComment(
zapRequest: Note,
zapEvent: Note,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
LaunchedEffect(key1 = zapRequest.idHex) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
}
if (it.event?.content() != "" && kind == "zap") {
Row(Modifier.fillMaxWidth()) {
it.event?.let {
TranslatableRichTextViewer(
content = it.content(),
canPreview = true,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
}
}
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
}
@Composable
private fun AuthorPictureAndComment(
author: User,
comment: String?,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val modifier = if (!comment.isNullOrBlank()) {
Modifier.fillMaxWidth()
} else {
Modifier
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
FastNoteAuthorPicture(
author = author,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
if (!comment.isNullOrBlank()) {
Spacer(modifier = Modifier.width(5.dp))
TranslatableRichTextViewer(
content = comment,
canPreview = true,
tags = null,
modifier = Modifier.weight(1f),
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account,
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
val author = it.author
if (author != null) {
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
}
}
}
@@ -275,15 +321,12 @@ fun AuthorGallery(
@Composable
fun FastNoteAuthorPicture(
note: Note,
author: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
// can't be null if here
val author = note.author ?: return
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return

View File

@@ -58,7 +58,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.math.BigDecimal
import java.net.URL
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -169,6 +171,8 @@ fun NoteComposeInner(
BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
@@ -515,6 +519,26 @@ fun NoteComposeInner(
ReactionsRow(note, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
noteEvent.author(),
noteEvent.inUrl(),
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
navController
)
if (!makeItShort) {
ReactionsRow(note, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
@@ -572,6 +596,66 @@ fun NoteComposeInner(
}
}
@Composable
fun DisplayHighlight(
highlight: String,
authorHex: String?,
url: String?,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n")
if (quote != null) {
TranslatableRichTextViewer(
quote,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
emptyList(),
backgroundColor,
accountViewModel,
navController
)
}
FlowRow() {
authorHex?.let { authorHex ->
val userBase = LocalCache.checkGetOrCreateUser(authorHex)
if (userBase != null) {
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user
if (user != null) {
CreateClickableText(
user.toBestDisplayName(),
"",
"User/${user.pubkeyHex}",
navController
)
}
}
}
url?.let { url ->
val validatedUrl = try {
URL(url)
} catch (e: Exception) {
Log.w("Note Compose", "Invalid URI: $url")
null
}
validatedUrl?.host?.let { host ->
Text("on ")
ClickableUrl(urlText = host, url = url)
}
}
}
}
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
@@ -781,24 +865,85 @@ fun BadgeDisplay(baseNote: Note) {
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
if (isImage || isVideo) {
val content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = event.id) {
withContext(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
content = if (isImage) {
ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
} else {
ZoomableUrlVideo(fullUrl, description, hash, uri)
}
}
ZoomableContentView(content = content, listOf(content))
} else {
UrlPreview(fullUrl, "$fullUrl ")
}
content?.let {
ZoomableContentView(content = it, listOf(it))
}
}
@Composable
fun FileStorageHeaderDisplay(baseNote: Note) {
val appContext = LocalContext.current.applicationContext
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteState by fileNote.live().metadata.observeAsState()
val note = noteState?.note
val eventBytes = (note?.event as? FileStorageEvent)
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
withContext(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
if (bytes != null) {
ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
null
}
}
}
}
content?.let {
ZoomableContentView(content = it, listOf(it))
}
}

View File

@@ -31,12 +31,13 @@ import androidx.compose.ui.window.Popup
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.*
import kotlin.math.roundToInt
@@ -51,105 +52,149 @@ fun PollNote(
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.load(zappedNote)
pollViewModel.load(account, zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
val color = if (
pollViewModel.consensusThreshold != null &&
optionTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f)
}
OptionNote(
poll_op.key,
poll_op.value,
pollViewModel,
baseNote,
accountViewModel,
canPreview,
backgroundColor,
navController
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = optionTally.toFloat()
@Composable
private fun OptionNote(
optionNumber: Int,
optionText: String,
pollViewModel: PollNoteViewModel,
baseNote: Note,
accountViewModel: AccountViewModel,
canPreview: Boolean,
backgroundColor: Color,
navController: NavController
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (!pollViewModel.canZap()) {
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
}
}
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
optionTally.second,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = optionTally.second,
progress = optionTally.first.toFloat()
)
Row(
verticalAlignment = Alignment.CenterVertically
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(horizontal = 10.dp)
.width(40.dp)
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) {
Text(
text = "${(optionTally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Text(
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
}
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
}
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
)
}
}
)
}
}
}
@@ -166,7 +211,7 @@ fun ZapVote(
clickablePrepend: @Composable () -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val zappedNote = zapsState?.note ?: return
var wantsToZap by remember { mutableStateOf(false) }
@@ -207,7 +252,7 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
scope.launch {
Toast
.makeText(
@@ -288,7 +333,17 @@ fun ZapVote(
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!optionWasZappedByLoggedInUser) {
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
}
}
}
if (optionWasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -315,8 +370,18 @@ fun ZapVote(
}
}
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
fontSize = 14.sp,
@@ -378,7 +443,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
account.defaultZapType
)
onDismiss()
}
@@ -404,7 +469,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
account.defaultZapType
)
onDismiss()
}
@@ -496,8 +561,7 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
onProgress
)
onDismiss()
}
@@ -522,8 +586,7 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
onProgress
)
onDismiss()
}

View File

@@ -20,8 +20,10 @@ class PollNoteViewModel {
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
var wasZappedByAuthor: Boolean = false
fun load(note: Note?) {
fun load(acc: Account, note: Note?) {
account = acc
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
@@ -31,6 +33,14 @@ class PollNoteViewModel {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !wasZappedByAuthor
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -87,14 +97,12 @@ class PollNoteViewModel {
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author === user } == true) {
pollNote!!.zaps
.any {
val event = it.value?.event as? LnZapEvent
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
}
}
return false
return pollNote!!.zaps
.any {
val zapEvent = it.value?.event as? LnZapEvent
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
}
}
fun zappedPollOptionAmount(option: Int): BigDecimal {

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -70,6 +71,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -90,23 +93,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ReplyReaction(baseNote, accountViewModel) {
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel) {
BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel)
LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel)
ZapReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex)
ViewCountReaction(baseNote.idHex, grayTint)
}
}
}
@@ -114,8 +117,10 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
@Composable
fun ReplyReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
@@ -125,7 +130,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -140,45 +145,40 @@ fun ReplyReaction(
}
}
) {
ReplyIcon()
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(iconSize),
tint = grayTint
)
}
if (showCounter) {
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
}
@Composable
private fun ReplyIcon() {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
private fun BoostReaction(
public fun BoostReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToBoost by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
@@ -215,14 +215,14 @@ private fun BoostReaction(
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = grayTint
)
}
@@ -231,24 +231,26 @@ private fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
@@ -271,14 +273,14 @@ fun LikeReaction(
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(heartSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(heartSize),
tint = grayTint
)
}
@@ -287,7 +289,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
@@ -295,29 +297,43 @@ fun LikeReaction(
@OptIn(ExperimentalFoundationApi::class)
fun ZapReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val zappedNote = zapsState?.note ?: return
val zapMessage = ""
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.then(Modifier.size(20.dp))
modifier = Modifier.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@@ -412,12 +428,12 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
if (wasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = BitcoinOrange
)
} else {
@@ -425,49 +441,46 @@ fun ZapReaction(
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(14.dp),
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
}
}
}
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = zappedNote?.zappedAmount()
}
}
Text(
showAmount(zapAmount),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
color = grayTint,
modifier = textModifier
)
}
@Composable
private fun ViewCountReaction(idHex: String) {
public fun ViewCountReaction(
idHex: String,
grayTint: Color,
iconSize: Dp = 20.dp,
barChartSize: Dp = 19.dp,
numberSize: Dp = 24.dp
) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
modifier = Modifier.size(barChartSize),
tint = grayTint
)
}
@@ -480,7 +493,7 @@ private fun ViewCountReaction(idHex: String) {
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = stringResource(R.string.view_count),
modifier = Modifier.height(24.dp),
modifier = Modifier.height(numberSize),
colorFilter = ColorFilter.tint(grayTint)
)
}

View File

@@ -21,6 +21,7 @@ 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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) {
this.account = account
@@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() {
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.selectedZapType = account.defaultZapType
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
@@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() {
fun sendPost() {
account?.changeZapAmounts(amountSet)
account?.changeDefaultZapType(selectedZapType)
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
val pubkeyHex = try {
@@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() {
fun hasChanged(): Boolean {
return (
amountSet != account?.zapAmountChoices ||
selectedZapType != account?.defaultZapType ||
amountSet != account?.zapAmountChoices ||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
@@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val postViewModel: UpdateZapAmountViewModel = viewModel()
val uri = LocalUriHandler.current
val zapTypes = listOf(
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) {
postViewModel.load(account)
if (nip47uri != null) {
@@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
decorFitsSystemWindows = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(modifier = Modifier.padding(10.dp).imePadding()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Spacer(modifier = Modifier.height(10.dp))
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
Row(
modifier = Modifier
.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
)
} ",
color = Color.White,
textAlign = TextAlign.Center
)
} ",
color = Color.White,
textAlign = TextAlign.Center
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextSpinner(
label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
postViewModel.selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle =
stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(
authTitle,
context,
scope,
keyguardLauncher
) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(authTitle, context, scope, keyguardLauncher) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
}
}

View File

@@ -60,6 +60,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
@@ -67,13 +68,14 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
var zappingProgress by remember { mutableStateOf(0f) }
val zapTypes = listOf(
Pair(LnZapEvent.ZapType.PUBLIC, "Public"),
Pair(LnZapEvent.ZapType.PRIVATE, "Private"),
Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"),
Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap")
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
Dialog(
@@ -150,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
TextSpinner(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Spacer(modifier = Modifier.height(5.dp))
Row(
modifier = Modifier
@@ -189,20 +203,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
}
TextSpinner(
label = "Zap Type",
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
options = zapOptions,
onSelect = {
selectedZapType = zapTypes[it].first
account.changeDefaultZapType(selectedZapType)
},
modifier = Modifier.fillMaxWidth()
)
}
}
}

View File

@@ -42,7 +42,8 @@ fun FeedView(
navController: NavController,
routeForLastRead: String?,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
@@ -50,7 +51,13 @@ fun FeedView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column {
Crossfade(
targetState = feedState,
@@ -88,7 +95,9 @@ fun FeedView(
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -34,6 +35,7 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)

View File

@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun LnZapFeedView(
viewModel: LnZapFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
fun RelayFeedView(
viewModel: RelayFeedViewModel,
accountViewModel: AccountViewModel,
enablePullRefresh: Boolean = true
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
val listState = rememberLazyListState()
@@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -1,17 +1,22 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>()
private data class ScrollState(val index: Int, val scrollOffset: Int)
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
}
@@ -25,16 +30,42 @@ fun rememberForeverLazyListState(
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
LazyListState(
savedIndex,
savedOffset
savedOffset.roundToInt()
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
}
}
return scrollState
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f
): PagerState {
val scrollState = rememberSaveable(saver = PagerState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
PagerState(
savedIndex,
savedOffset
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.currentPage
val lastOffset = scrollState.currentPageOffsetFraction
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
}
}

View File

@@ -54,6 +54,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
@@ -348,33 +349,46 @@ fun NoteMaster(
)
) {
Column() {
val eventContent = note.event?.content()
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
noteEvent.author(),
noteEvent.inUrl(),
false,
true,
backgroundColor,
accountViewModel,
navController
)
} else {
val eventContent = note.event?.content()
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (noteEvent is PollNoteEvent) {
PollNote(
note,
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
backgroundColor,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
if (noteEvent is PollNoteEvent) {
PollNote(
note,
canPreview,
backgroundColor,
accountViewModel,
navController
)
}
}
}

View File

@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun UserFeedView(
viewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
@@ -54,8 +55,16 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
@@ -83,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
@@ -164,6 +174,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
return account.decryptContent(note)
}
fun decryptZap(note: Note): Event? {
return account.decryptZapContentAuthor(note)
}
fun hide(user: User) {
account.hideUser(user.pubkeyHex)
}

View File

@@ -233,7 +233,7 @@ fun ChannelScreen(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
channelScreenModel.upload(it, "", context)
channelScreenModel.upload(it, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(

View File

@@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
chatRoomScreenModel.upload(it, "", context)
chatRoomScreenModel.upload(it, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(

View File

@@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
val accountState by accountStateViewModel.accountContent.collectAsState()
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
NewNoteButton(state.account, accountViewModel, navController)
}
}
}
}
if (currentRoute(navController) == Route.Message.base) {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
if (currentRoute(navController) == Route.Message.base) {
NewChannelButton(state.account)
}
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
NewImageButton(accountViewModel, navController)
}
}
}
}

View File

@@ -496,7 +496,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
@@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it) { response ->
account.sendZapPaymentRequestFor(it, null) { response ->
if (response is PayInvoiceSuccessResponse) {
scope.launch {
Toast.makeText(
@@ -760,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -780,7 +780,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -801,7 +801,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -821,7 +821,7 @@ fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
UserFeedView(feedViewModel, accountViewModel, navController)
UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -840,7 +840,7 @@ fun TabFollowers(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
UserFeedView(feedViewModel, accountViewModel, navController)
UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -859,7 +859,7 @@ fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
LnZapFeedView(feedViewModel, accountViewModel, navController)
LnZapFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -878,7 +878,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -913,7 +913,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RelayFeedView(feedViewModel, accountViewModel)
RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
}
}
}

View File

@@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -127,14 +130,62 @@ fun SearchScreen(
}
}
class SearchBarViewModel : ViewModel() {
var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
val searchResultsChannels = mutableStateOf<List<Channel>>(emptyList())
val hashtagResults = mutableStateOf<List<String>>(emptyList())
val isTrailingIconVisible by
derivedStateOf {
searchValue.isNotBlank()
}
fun updateSearchValue(newValue: String) {
searchValue = newValue
}
private fun runSearch() {
if (searchValue.isBlank()) {
hashtagResults.value = emptyList()
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
return
}
hashtagResults.value = findHashtags(searchValue)
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
}
fun clean() {
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
runSearch()
}
fun invalidateData() {
bundler.invalidate()
}
fun isSearching() = searchValue.isNotBlank()
}
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
var searchValue by remember { mutableStateOf("") }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
val searchBarViewModel: SearchBarViewModel = viewModel()
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.isNotBlank()
}
}
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
}
LaunchedEffect(db) {
if (searchValue.length > 1) {
withContext(Dispatchers.IO) {
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value =
LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }
.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
withContext(Dispatchers.IO) {
if (searchBarViewModel.isSearching()) {
println("Search Active")
searchBarViewModel.invalidateData()
}
}
}
@@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
hashtagResults.value = findHashtags(it)
if (it.length >= 4) {
if (it.length >= 2) {
onlineSearch.search(it.trim())
}
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
@@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue,
value = searchBarViewModel.searchValue,
onValueChange = {
searchValue = it
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
@@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
},
trailingIcon = {
if (isTrailingIconVisible) {
if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
searchBarViewModel.clean()
onlineSearch.clear()
}
) {
@@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
if (searchValue.isNotBlank()) {
if (searchBarViewModel.isSearching()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
),
state = listState
) {
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
navController.navigate("Hashtag/$item")
}
}
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
@@ -296,7 +330,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}

View File

@@ -0,0 +1,487 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.Manifest
import android.net.Uri
import android.os.Build
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
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.model.Note
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun VideoScreen(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
scrollToTop: Boolean = false
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel) {
VideoFeedFilter.account = account
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start")
VideoFeedFilter.account = account
NostrVideoDataSource.start()
videoFeedView.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Video Stop")
NostrVideoDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
}
}
}
@Composable
fun FeedView(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) {
val feedState by videoFeedView.feedContent.collectAsState()
Box() {
Column {
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {}
}
is FeedState.Loaded -> {
SlidingCarousel(
state.feed,
accountViewModel,
navController,
scrollStateKey,
scrollToTop
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
feed: MutableState<List<Note>>,
accountViewModel: AccountViewModel,
navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
if (scrollToTop) {
LaunchedEffect(Unit) {
pagerState.scrollToPage(page = 0)
}
}
VerticalPager(
pageCount = feed.value.size,
state = pagerState,
beyondBoundsPageCount = 1,
modifier = Modifier.fillMaxSize(1f),
key = { index ->
feed.value.getOrNull(index)?.idHex ?: "$index"
}
) { index ->
feed.value.getOrNull(index)?.let { note ->
RenderVideoOrPictureNote(note, accountViewModel, navController)
}
}
}
@Composable
private fun RenderVideoOrPictureNote(
note: Note,
accountViewModel: AccountViewModel,
navController: NavController
) {
val noteEvent = note.event
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val loggedIn = account.userProfile()
var moreActionsExpanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize(1f)) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
}
}
}
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
Column(Modifier.weight(1f)) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
}
Column(
Modifier
.padding(start = 10.dp, end = 10.dp)
.height(60.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(note, Modifier.weight(1f))
IconButton(
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
RelayBadges(baseNote = note)
}
}
}
}
Column(
Modifier
.width(65.dp)
.padding(bottom = 10.dp),
verticalArrangement = Arrangement.Center
) {
Row(horizontalArrangement = Arrangement.Center) {
ReactionsColumn(note, accountViewModel, navController)
}
}
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = noteRelays
val uri = LocalUriHandler.current
FlowRow() {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
}
}
@Composable
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
var wantsToQuote by remember {
mutableStateOf<Note?>(null)
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
}
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
/*
ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToReplyTo = baseNote
}
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) {
var wantsToPost by remember {
mutableStateOf(false)
}
var pickedURI by remember {
mutableStateOf<Uri?>(null)
}
val scope = rememberCoroutineScope()
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch {
// awaits an refresh on the list
delay(250)
val route = Route.Video.route.replace("{scrollToTop}", "true")
navController.navigate(route) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = false }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
if (wantsToPost) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
wantsToPost = false
showGallerySelect = false
pickedURI = uri
}
)
}
showGallerySelect = true
} else {
LaunchedEffect(key1 = accountViewModel) {
cameraPermissionState.launchPermissionRequest()
}
}
}
pickedURI?.let {
NewMediaView(
uri = it,
onClose = { pickedURI = null },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
navController = navController
)
}
if (postViewModel.isUploadingImage) {
ShowProgress(postViewModel)
} else {
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
}
@Composable
private fun ShowProgress(postViewModel: NewMediaModel) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = postViewModel.uploadingPercentage.value,
modifier = Modifier
.size(55.dp)
.background(MaterialTheme.colors.background)
.clip(CircleShape),
strokeWidth = 5.dp
)
postViewModel.uploadingDescription.value?.let {
Text(
it,
color = MaterialTheme.colors.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="122.88dp"
android:height="111.34dp"
android:viewportWidth="122.88"
android:viewportHeight="111.34">
<path
android:fillColor="#FF9C9C9C"
android:pathData="M23.59,0h75.7a23.68,23.68 0,0 1,23.59 23.59L122.88,87.75A23.56,23.56 0,0 1,116 104.41l-0.22,0.2a23.53,23.53 0,0 1,-16.44 6.73L23.59,111.34a23.53,23.53 0,0 1,-16.66 -6.93l-0.2,-0.22A23.46,23.46 0,0 1,0 87.75L0,23.59A23.66,23.66 0,0 1,23.59 0ZM54,47.73 L79.25,65.36a3.79,3.79 0,0 1,0.14 6.3L54.22,89.05a3.75,3.75 0,0 1,-2.4 0.87A3.79,3.79 0,0 1,48 86.13L48,50.82h0A3.77,3.77 0,0 1,54 47.73ZM7.35,26.47h14L30.41,7.35L23.59,7.35A16.29,16.29 0,0 0,7.35 23.59v2.88ZM37.05,7.35 L28,26.47L53.36,26.47L62.43,7.38v0ZM69.05,7.35L59.92,26.47h24.7L93.7,7.35ZM100.37,7.35L91.26,26.47h24.27L115.53,23.59a16.32,16.32 0,0 0,-15.2 -16.21ZM115.57,34.03L7.35,34.03L7.35,87.75A16.21,16.21 0,0 0,12 99.05l0.17,0.16A16.19,16.19 0,0 0,23.59 104h75.7a16.21,16.21 0,0 0,11.3 -4.6l0.16,-0.18a16.17,16.17 0,0 0,4.78 -11.46L115.53,34.06Z"/>
</vector>

View File

@@ -289,4 +289,49 @@
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">A kép a bejegyzés óta ugyanaz</string>
<string name="hash_verification_failed">A kép megváltozott. Lehet, hogy a szerző nem látta a változást</string>
<string name="content_description_add_image">Kép Hozzáadása</string>
<string name="content_description_add_video">Videó Hozzáadása</string>
<string name="content_description_add_document">Dokumentum Hozzáadása</string>
<string name="add_content">Létrehoz és Hozzáad</string>
<string name="content_description">A tartalom leírása</string>
<string name="content_description_example">Naplementekor egy kék csónak egy fehér homokos tengerparton</string>
<string name="zap_type">Zap Típusa</string>
<string name="zap_type_explainer">Zap típus minden opcióhoz</string>
<string name="zap_type_public">Publikus</string>
<string name="zap_type_public_explainer">Mindenki láthatja a tranzakciót és az üzenetet</string>
<string name="zap_type_private">Privát</string>
<string name="zap_type_private_explainer">A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja</string>
<string name="zap_type_anonymous">Névtelen</string>
<string name="zap_type_anonymous_explainer">A címzett és a nyilvánosság nem tudja ki küldte a fizetést</string>
<string name="zap_type_nonzap">Nem Zap</string>
<string name="zap_type_nonzap_explainer">Nostr-ban nyoma sincs, csak a Lightning-ben</string>
<string name="file_server">Fájl Szerver</string>
<string name="zap_forward_lnAddress">LnCím vagy @Felhasználó</string>
<string name="upload_server_imgur">imgur.com - kevésbé biztonságos</string>
<string name="upload_server_imgur_explainer">Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja</string>
<string name="upload_server_nostrimg">nostrimg.com - kevésbé biztonságos</string>
<string name="upload_server_nostrimg_explainer">Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja</string>
<string name="upload_server_imgur_nip94">Ellenőrizhető Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa</string>
<string name="upload_server_nostrimg_nip94">Ellenőrizhető NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa</string>
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
</resources>

View File

@@ -280,9 +280,56 @@
<string name="looking_for_event">"Zoeken naar event %1$s"</string>
<string name="custom_zaps_add_a_message">Een openbaar bericht toevoegen</string>
<string name="custom_zaps_add_a_message_private">Een privébericht toevoegen</string>
<string name="custom_zaps_add_a_message_nonzap">Een invoice bericht toevoegen</string>
<string name="custom_zaps_add_a_message_example">Bedankt voor al jullie werk!</string>
<string name="lightning_create_and_add_invoice">Maken en toevoegen</string>
<string name="poll_author_no_vote">Poll auteurs kunnen niet stemmen in hun eigen poll.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">De afbeelding is onveranderd sinds de post</string>
<string name="hash_verification_failed">De afbeelding is veranderd. De auteur heeft de wijziging misschien niet gezien</string>
<string name="content_description_add_image">Afbeelding toevoegen</string>
<string name="content_description_add_video">Video toevoegen</string>
<string name="content_description_add_document">Document toevoegen</string>
<string name="add_content">Voeg toe aan bericht</string>
<string name="content_description">Beschrijving van de inhoud</string>
<string name="content_description_example">Een blauwe boot in een wit zandstrand bij zonsondergang</string>
<string name="zap_type">Zap type</string>
<string name="zap_type_explainer">Zap type voor alle opties</string>
<string name="zap_type_public">Publiek</string>
<string name="zap_type_public_explainer">Iedereen kan de transactie en het bericht zien</string>
<string name="zap_type_private">Privé</string>
<string name="zap_type_private_explainer">Verzender en ontvanger kunnen elkaar zien en het bericht lezen</string>
<string name="zap_type_anonymous">Anoniem</string>
<string name="zap_type_anonymous_explainer">Ontvanger en het publiek weten niet wie de betaling verzond</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">Geen spoor binnen Nostr, alleen in Lightning</string>
<string name="file_server">File server</string>
<string name="zap_forward_lnAddress">LnAddress of @User</string>
<string name="upload_server_imgur">imgur.com - minder beveiligd</string>
<string name="upload_server_imgur_explainer">Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen</string>
<string name="upload_server_nostrimg">nostrimg.com - minder beveiligd</string>
<string name="upload_server_nostrimg_explainer">Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen</string>
<string name="upload_server_imgur_nip94">Verifieerbare Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
<string name="upload_server_nostrimg_nip94">Verifieerbare NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
<string name="upload_server_relays_nip95">Uw relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.</string>
</resources>

View File

@@ -0,0 +1,286 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">QR குறியீட்டைச் சுட்டிக்காட்டவும்</string>
<string name="show_qr">QR ஐக் காட்டவும்</string>
<string name="profile_image">சுயவிவரப் படம்</string>
<string name="scan_qr">QR ஐ ஸ்கேன் செய்யவும்</string>
<string name="show_anyway">பரவாயில்லை, காட்டவும்</string>
<string name="post_was_flagged_as_inappropriate_by">குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்</string>
<string name="post_not_found">குறிப்பு கிடைக்கவில்லை</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை</string>
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
<string name="group_picture">குழுப் படம்</string>
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
<string name="spam">ஸ்பேம்</string>
<string name="impersonation">ஆள்மாறாட்டம்</string>
<string name="illegal_behavior">சட்டவிரோத நடத்தை</string>
<string name="unknown">தெரியாத</string>
<string name="relay_icon">ரிலே ஐகான்</string>
<string name="unknown_author">எழுதியவர் தெரியவில்லை</string>
<string name="copy_text">உரையை நகலெடுக்கவும்</string>
<string name="copy_user_pubkey">எழுதியவர் IDஐ நகலெடுக்கவும்</string>
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
<string name="report_illegal_behaviour">சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்</string>
<string name="login_with_a_private_key_to_be_able_to_reply">பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_like_posts">குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="no_zap_amount_setup_long_press_to_change">ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="zaps">ஜாப்கள்</string>
<string name="view_count">பார்வை எண்ணிக்கை</string>
<string name="boost">உயர்த்து</string>
<string name="boosted">உயர்த்தப்பட்டது</string>
<string name="quote">மேற்கோள் காட்டு</string>
<string name="new_amount_in_sats">புதிய தொகை (ஸாட்களில்)</string>
<string name="add">சேர்</string>
<string name="replying_to">"பதில் பெறுநர் "</string>
<string name="and">மற்றும்</string>
<string name="in_channel">"அலை வரிசையில் "</string>
<string name="profile_banner">சுயவிவர பேனர்</string>
<string name="following">" பின்பற்றப் படுவோர்"</string>
<string name="followers">" பின்பற்றுவோர்"</string>
<string name="profile">சுயவிவரம்</string>
<string name="security_filters">பாதுகாப்பு வடிப்பான்கள்</string>
<string name="log_out">வெளியேறு</string>
<string name="show_more">மேலும் காட்டு</string>
<string name="lightning_invoice">லைட்நிங் விலைப்பட்டியல்</string>
<string name="pay">செலுத்து</string>
<string name="lightning_tips">லைட்நிங் இனாம்</string>
<string name="note_to_receiver">பெறுபவருக்கான குறிப்பு</string>
<string name="thank_you_so_much">மிக்க நன்றி!</string>
<string name="amount_in_sats">தொகை (ஸாட்களில்)</string>
<string name="send_sats">ஸாட்கள் அனுப்பவும்</string>
<string name="error_parsing_preview_for">"%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"</string>
<string name="preview_card_image_for">" %1$s க்கான முன்னோட்ட அட்டைப் படம்"</string>
<string name="new_channel">புதிய அலைவரிசை</string>
<string name="channel_name">அலைவரிசைப் பெயர்</string>
<string name="my_awesome_group">எனது அருமையான குழு</string>
<string name="picture_url">பட Url</string>
<string name="description">தகவல்</string>
<string name="about_us">"எங்களைப் பற்றி.. "</string>
<string name="what_s_on_your_mind">உங்கள் மனதில் என்ன உள்ளது?</string>
<string name="post">குறிப்பு</string>
<string name="save">சேமி</string>
<string name="create">உருவாக்கு</string>
<string name="cancel">ரத்து</string>
<string name="failed_to_upload_the_image">படத்தை பதிவேற்றுதல் தோல்வி</string>
<string name="relay_address">ரிலே முகவரி</string>
<string name="posts">குறிப்புகள்</string>
<string name="bytes">பைட்டுகள்</string>
<string name="errors">பிழைகள்</string>
<string name="home_feed">முகப்பு ஊட்டம்</string>
<string name="private_message_feed">தனிப்பட்ட செய்தி ஊட்டம்</string>
<string name="public_chat_feed">பொது செய்தி ஊட்டம்</string>
<string name="global_feed">முழுதளாவிய ஊட்டம்</string>
<string name="search_feed">தேடுதல் ஊட்டம்</string>
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படம் URL</string>
<string name="banner_url">பேனர் URL</string>
<string name="website_url">வலைத்தள URL</string>
<string name="ln_address">LN முகவரி </string>
<string name="ln_url_outdated">LN URL (காலாவதியானது)</string>
<string name="image_saved_to_the_gallery">படம் கேலரியில் சேமிக்கப் பட்டது</string>
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
<string name="edits_the_channel_metadata">அலைவரிசை பற்றிய தகவல்களை மாற்றும்</string>
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
<string name="replies">பதில்கள்</string>
<string name="follows">"பின்தொடர படுவோர்"</string>
<string name="reports">"புகார்கள்"</string>
<string name="more_options">மேலும் </string>
<string name="relays">" ரிலேகள்"</string>
<string name="website">இணையதளம்</string>
<string name="lightning_address">லைட்னிங் முகவரி</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது</string>
<string name="copy_private_key_to_the_clipboard">கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
<string name="show_password">கடவுச்சொல்லை காட்டவும்</string>
<string name="hide_password">கடவுச்சொல்லை மறைக்கவும்</string>
<string name="invalid_key">தவறான சாவி</string>
<string name="i_accept_the">"பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "</string>
<string name="terms_of_use">பயன்பாட்டு விதிமுறைகளை</string>
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்றுக்கொள்வது தேவை</string>
<string name="key_is_required">சாவி தேவை</string>
<string name="login">உள்நுழை</string>
<string name="generate_a_new_key">புதிய சாவியை உருவாக்குங்கள்</string>
<string name="loading_feed">ஊட்டம் ஏற்றப்படுகிறது</string>
<string name="error_loading_replies">"பதில்களை ஏற்றுவதில் பிழை: "</string>
<string name="try_again">மீண்டும் முயற்சி செய்</string>
<string name="feed_is_empty">ஊட்டம் காலியாக உள்ளது.</string>
<string name="refresh">புதுப்பி</string>
<string name="created">உருவாக்கப்பட்டது</string>
<string name="with_description_of">விளக்கம்:</string>
<string name="and_picture">மற்றும் படம்</string>
<string name="changed_chat_name_to">மாற்றப்பட்ட அரட்டை பெயர்</string>
<string name="description_to">விளக்கம்</string>
<string name="and_picture_to">மற்றும் படம்</string>
<string name="leave">வெளியேறு</string>
<string name="unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="channel_created">அலைவரிசை உருவாக்கப் பட்டது</string>
<string name="channel_information_changed_to">"அலைவரிசை தகவல் மாற்றப்பட்டது"</string>
<string name="public_chat">பொது அரட்டை</string>
<string name="posts_received">குறிப்புகள் பெறப்பட்டன</string>
<string name="remove">அகற்று</string>
<string name="translations_auto">தானியங்கி</string>
<string name="translations_translated_from">மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:</string>
<string name="translations_to"></string>
<string name="translations_show_in_lang_first">முதலில் %1$s இல் காண்பி</string>
<string name="translations_always_translate_to_lang">எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்</string>
<string name="translations_never_translate_from_lang">%1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்</string>
<string name="never">ஒருபோதும் இல்லை</string>
<string name="now">இப்போது</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">நிர்வாணம்</string>
<string name="profanity_hateful_speech">அவதூறு / வெறுக்கத்தக்க பேச்சு</string>
<string name="report_hateful_speech">வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்</string>
<string name="report_nudity_porn">நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்</string>
<string name="others">மற்றவைகள்</string>
<string name="mark_all_known_as_read">அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_new_as_read">அனைத்து புதியதையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_as_read">அனைத்தையும் படித்ததாக குறிக்கவும்</string>
<string name="backup_keys">காப்பு சாவிகள்</string>
<string name="account_backup_tips_md">
## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
\n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
\n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
\ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
\ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
</string>
<string name="secret_key_copied_to_clipboard">ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது</string>
<string name="copy_my_secret_key">எனது ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="biometric_authentication_failed">அங்கீகரிப்பு தோல்வியுற்றது</string>
<string name="biometric_error">பிழை</string>
<string name="badge_created_by">" %1$s ஆல் உருவாக்கப்பட்டது"</string>
<string name="badge_award_image_for">" %1$s க்கான பேட்ஜ் விருது படம்"</string>
<string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string>
<string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string>
<string name="copied_note_text_to_clipboard">குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது</string>
<string name="copied_user_id_to_clipboard">கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது</string>
<string name="copied_note_id_to_clipboard">கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது</string>
<string name="select_text_dialog_top">உரையைத் தேர்ந்தெடுக்கவும்</string>
<string name="private_conversation_notification">"தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."</string>
<string name="account_switch_add_account_dialog_title">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="drawer_accounts">கணக்குகள்</string>
<string name="account_switch_select_account">கணக்கைத் தேர்ந்தெடுக்கவும்</string>
<string name="account_switch_add_account_btn">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="account_switch_active_account">செயலில் உள்ள கணக்கு</string>
<string name="account_switch_has_private_key">தனிப்பட்ட சாவி உள்ளது</string>
<string name="account_switch_pubkey_only">படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை</string>
<string name="back">பின்</string>
<string name="quick_action_select">தேர்ந்தெடுக்கவும்</string>
<string name="quick_action_share_browser_link">உலாவி இணைப்பைப் பகிரவும்</string>
<string name="quick_action_share">பகிர்</string>
<string name="quick_action_copy_user_id">ஆசிரியர் ஐடி</string>
<string name="quick_action_copy_note_id">குறிப்பு ஐடி</string>
<string name="quick_action_copy_text">உரையை நகலெடுக்கவும்</string>
<string name="quick_action_delete">அழி</string>
<string name="quick_action_unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="quick_action_follow">பின்தொடர்</string>
<string name="quick_action_request_deletion_alert_title">நீக்க கோரிக்கை செய்</string>
<string name="quick_action_request_deletion_alert_body">நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.</string>
<string name="quick_action_block_dialog_btn">தடைசெய்</string>
<string name="quick_action_delete_dialog_btn">அழி</string>
<string name="quick_action_block">தடைசெய்</string>
<string name="quick_action_report">புகார் </string>
<string name="quick_action_delete_button">அழி</string>
<string name="quick_action_dont_show_again_button">மீண்டும் காட்ட வேண்டாம்</string>
<string name="report_dialog_spam">ஸ்பேம் அல்லது மோசடிகள்</string>
<string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string>
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
<string name="report_dialog_additional_reason_label">கூடுதல் சூழல்</string>
<string name="report_dialog_select_reason_label">காரணம்</string>
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…</string>
<string name="report_dialog_post_report_btn">புகார் அளிக்கவும்</string>
<string name="report_dialog_title">தடு மற்றும் புகாரளி</string>
<string name="block_only">தடு</string>
<string name="bookmarks">புக்மார்க்குகள்</string>
<string name="private_bookmarks">தனிப்பட்ட புக்மார்க்குகள்</string>
<string name="public_bookmarks">பொது புக்மார்க்குகள்</string>
<string name="add_to_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="add_to_public_bookmarks">பொது புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="remove_from_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="remove_from_public_bookmarks">பொது புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="wallet_connect_service">பணப்பை இணைப்பு சேவை</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 / hex தனிப்பட்ட சாவி</string>
<string name="pledge_amount_in_sats">உறுதிமொழி தொகை (ஸாட்களில்)</string>
<string name="post_poll"></string>
<string name="poll_heading_required">தேவையான புலங்கள்:</string>
<string name="poll_zap_recipients">ஜாப் பெறுநர்கள்</string>
<string name="poll_primary_description">முதன்மை வாக்கெடுப்பு விளக்கம்…</string>
<string name="poll_option_index">விருப்பம் %s</string>
<string name="poll_option_description">வாக்கெடுப்பு விருப்ப விளக்கம்</string>
<string name="poll_heading_optional">விருப்ப புலங்கள்:</string>
<string name="poll_zap_value_min">குறைந்தபட்ச ஜாப்</string>
<string name="poll_zap_value_max">அதிகபட்ச ஜாப்</string>
<string name="poll_consensus_threshold">ஒருமித்த கருத்து</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">நிறைவு செய்வது</string>
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_nonzap">விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_example">உங்கள் எல்லா வேலைகளுக்கும் நன்றி!</string>
<string name="lightning_create_and_add_invoice">உருவாக்கி சேர்க்கவும்</string>
<string name="poll_author_no_vote">வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.</string>
<string name="hash_verification_passed">குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை</string>
<string name="hash_verification_failed">படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்</string>
<string name="content_description_add_image">படத்தைச் சேர்க்கவும்</string>
<string name="content_description_add_video">வீடியோ சேர்க்கவும்</string>
<string name="content_description_add_document">ஆவணத்தைச் சேர்க்கவும்</string>
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
</resources>

View File

@@ -303,8 +303,49 @@
<string name="content_description_add_image">Add Image</string>
<string name="content_description_add_video">Add Video</string>
<string name="content_description_add_document">Add Document</string>
<string name="add_content">Create and Add</string>
<string name="add_content">Add to Message</string>
<string name="content_description">Description of the contents</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
<string name="zap_type">Zap Type</string>
<string name="zap_type_explainer">Zap Type for all options</string>
<string name="zap_type_public">Public</string>
<string name="zap_type_public_explainer">Everybody can see the transaction and message</string>
<string name="zap_type_private">Private</string>
<string name="zap_type_private_explainer">Sender and Receiver can see each other and read the message</string>
<string name="zap_type_anonymous">Anonymous</string>
<string name="zap_type_anonymous_explainer">Receiver and the public does not know who sent the payment</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string>
<string name="file_server">File Server</string>
<string name="zap_forward_lnAddress">LnAddress or @User</string>
<string name="upload_server_imgur">imgur.com - trusted</string>
<string name="upload_server_imgur_explainer">Imgur can modify the file</string>
<string name="upload_server_nostrimg">nostrimg.com - trusted</string>
<string name="upload_server_nostrimg_explainer">NostrImg can modify the file</string>
<string name="upload_server_nostrbuild">nostr.build - trusted</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Checks if Imgur modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Checks if NostrImg modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Checks if Nostr.build modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
</resources>

View File

@@ -1,8 +1,8 @@
buildscript {
ext {
fragment_version = "1.5.6"
fragment_version = "1.5.7"
lifecycle_version = '2.6.1'
compose_ui_version = '1.5.0-alpha03'
compose_ui_version = '1.4.2'
nav_version = "2.5.3"
room_version = "2.4.3"
accompanist_version = '0.30.0'