Compare commits

..
149 Commits
Author SHA1 Message Date
Vitor Pamplona 5cc0bf54be v0.29.2 2023-03-24 17:25:16 -04:00
Vitor Pamplona 27e053dcd2 Merge remote-tracking branch 'origin/HEAD' 2023-03-24 17:04:50 -04:00
Vitor PamplonaandGitHub aaa0678c18 Merge pull request #314 from believethehype/main
#SkullofSatoshi
2023-03-24 17:04:28 -04:00
Vitor Pamplona cd03ed354f Adjusts zap animation size 2023-03-24 17:03:58 -04:00
Believethehype 10f3551c4f Update HashtagIcon.kt 2023-03-24 21:51:54 +01:00
Believethehype 6bb1f260ea Update HashtagIcon.kt 2023-03-24 21:49:16 +01:00
Believethehype d889c675a0 Update HashtagIcon.kt 2023-03-24 21:47:35 +01:00
Believethehype 757f6982bd Merge branch 'main' of https://github.com/believethehype/amethyst 2023-03-24 21:35:11 +01:00
Believethehype d88581ac88 #SkullOfSatoshi 2023-03-24 21:34:29 +01:00
Vitor Pamplona 2b60c24c86 0.29.1 2023-03-24 13:27:29 -04:00
Vitor Pamplona 99f84e891e Merge remote-tracking branch 'origin/HEAD' 2023-03-24 13:19:51 -04:00
Vitor PamplonaandGitHub efa0672241 Merge pull request #312 from greenart7c3/main
add missing pt-br translations
2023-03-24 13:17:35 -04:00
Vitor Pamplona 987a3026ac Support for Translateable RichText on About Me section of the Profile 2023-03-24 13:15:23 -04:00
Vitor Pamplona bbf33df04c Adds an Image Carousel when there are more than 1 image in the post. 2023-03-24 12:33:16 -04:00
Vitor Pamplona 8b81e2e279 Requesting reposts from UserProfile 2023-03-24 10:56:41 -04:00
Vitor Pamplona 3d8870c342 Simplifying the filter for home 2023-03-24 10:56:23 -04:00
Vitor Pamplona 1a923cefaf Removes the tag from the autotranslation index to avoid translating the name itself. 2023-03-24 10:03:56 -04:00
Vitor Pamplona eed62c36b9 Creates a Loading screen to get events before redirecting them to the appropriate screen (DMs, Channels, Threads) 2023-03-24 09:58:28 -04:00
greenart7c3 839a691a67 add missing pt-br translations 2023-03-24 08:13:30 -03:00
Vitor Pamplona e910260375 Merge remote-tracking branch 'origin/HEAD' 2023-03-23 17:58:16 -04:00
Vitor PamplonaandGitHub 7c9510a6c9 Merge pull request #310 from lgleasain/main
adding in support for clicking on lightning withdrawal links intenting out to a wallet
2023-03-23 17:53:24 -04:00
Vitor Pamplona 3030d474e2 Migrates per-relay stats from event counter to bytes transmitted. 2023-03-23 17:52:26 -04:00
Vitor Pamplona d9c0b8cd20 Fixes mark for users that pledged sats to a bounty 2023-03-23 17:20:33 -04:00
Lance Gleason 51fbf1b1a5 adding in support for clicking on lightning withdrawal links and having it go to a wallet 2023-03-23 14:52:15 -06:00
Vitor Pamplona ba33b5e1c6 removes prefixes of unsecured websocket addresses 2023-03-23 16:30:52 -04:00
Vitor Pamplona 81d9037807 Simplifying Reply unwrap for messages made by the logged in Person 2023-03-23 15:47:20 -04:00
Vitor Pamplona 79c894dcfd Moving Markdown default styling to a singleton 2023-03-23 15:21:44 -04:00
Vitor Pamplona e4c98cf39f Fixing spaces after url 2023-03-23 15:21:25 -04:00
Vitor Pamplona ed1e080fbe Support for r tags 2023-03-23 15:20:35 -04:00
Vitor Pamplona afef10189b v0.29.0 2023-03-23 12:32:14 -04:00
Vitor Pamplona 1a66319a6d Support to pledge additional bounty amounts. 2023-03-23 12:24:46 -04:00
Vitor Pamplona 7a1470ef7b BugFix: Don't add e tags with addresses. 2023-03-23 12:24:11 -04:00
Vitor Pamplona be09577db1 Adds extra space before uncited hashtags. 2023-03-23 11:07:29 -04:00
Vitor Pamplona f91a0d4cd0 Fixes hashtag links 2023-03-23 11:07:02 -04:00
Vitor Pamplona 4589e6f329 Adding Bounty information to Note 2023-03-23 10:49:01 -04:00
Vitor Pamplona 7375c9e9e3 Adds border for Markdown texts 2023-03-23 10:46:59 -04:00
Vitor Pamplona ccd71a8023 Adds default banner image to LongFormPosts if none is found 2023-03-23 09:04:44 -04:00
Vitor Pamplona 3a76d176c5 Adds user's picture and background to LongForm posts without a default image 2023-03-23 08:58:18 -04:00
Vitor Pamplona 6626be8e1c Displays hashtags that are not in the text at the bottom of each note 2023-03-23 08:57:28 -04:00
Vitor Pamplona dd9ea5f27e Support for video uploads. 2023-03-22 16:50:18 -04:00
Vitor Pamplona cb20e14822 Sending Addressable notes to Habla.news: Fixes: https://github.com/vitorpamplona/amethyst/issues/295 2023-03-22 15:47:01 -04:00
Vitor Pamplona b9aa099a1e Adding space on inner notes 2023-03-22 15:03:14 -04:00
Vitor Pamplona 1e8bc10ad1 Showing progress bars when Zaps are requested 2023-03-22 14:24:33 -04:00
Vitor Pamplona ba40b1815f Checks if the requested amount to matches the received LN Invoice's amount 2023-03-22 14:23:42 -04:00
Vitor Pamplona cf2c56e088 Adds instructions of use for the wallet connect 2023-03-22 11:56:12 -04:00
Vitor Pamplona 896758121b Blocks translations of @npub/@note tags in the text. 2023-03-22 11:24:58 -04:00
Vitor Pamplona f0f9726ede Support for Wallet Connect Api 2023-03-22 09:45:21 -04:00
Vitor Pamplona ddb3990c11 Fully hiding chat messages from hidden users. 2023-03-21 14:15:58 -04:00
Vitor Pamplona 8a72e3b7ac Reducing the amount of preloaded messages in chat. 2023-03-21 14:15:26 -04:00
Vitor Pamplona aae1a65cba Refactoring: notNull filter is not needed. 2023-03-21 14:13:32 -04:00
Vitor PamplonaandGitHub fb017c3903 Merge pull request #299 from believethehype/main
Minor Cosmetics for HashTagIcons
2023-03-21 08:29:23 -04:00
Believethehype 0c1b9195d7 higher resolution icons, fixed descriptions, positioning 2023-03-21 10:27:38 +01:00
Believethehype 1fe58810a0 2 additional popular community hashtagicons
plebchain / coffeechain
2023-03-21 08:46:19 +01:00
Believethehype 3cde9bcdd5 Update HashtagIcon.kt 2023-03-21 08:21:56 +01:00
Believethehype 0c3b73f4d3 Minor Cosmetics for HashTagIcons
- Made Icons a little bit larger again, as it was before last (scaling fix) update.

- Added some more popular variations of currently supported hts.
2023-03-21 08:19:18 +01:00
Vitor Pamplona 25883d00ce v0.28.1 2023-03-20 20:57:12 -04:00
Vitor PamplonaandGitHub d04bbfc640 Merge pull request #297 from believethehype/main
Better scaling for HashtagIcons for different System font sizes
2023-03-20 20:50:11 -04:00
Vitor Pamplona 7da837f49e Always hide the note if the user has selected to hide user. 2023-03-20 20:46:25 -04:00
Vitor Pamplona 9709ffe388 Removes the possibility of duplicated event in Bookmarks 2023-03-20 20:45:36 -04:00
Believethehype f7cc656288 Update HashtagIcon.kt 2023-03-21 01:08:15 +01:00
Believethehype 3ba979e513 Update HashtagIcon.kt 2023-03-21 00:55:27 +01:00
Vitor Pamplona eadb4a108c Fixing EO Translations 2023-03-20 18:50:25 -04:00
Vitor Pamplona 8b44816520 v0.28.0 2023-03-20 18:26:03 -04:00
Vitor Pamplona 2fb673acf0 Bookmarks 2023-03-20 18:16:07 -04:00
Believethehype 8401f09475 Update HashtagIcon.kt 2023-03-20 20:39:32 +01:00
Believethehype c4400ded28 Better scaling for HashtagIcons for different System font sizes 2023-03-20 20:32:56 +01:00
Vitor Pamplona fc37789727 Adds a filter for messages by the author in public chats from all relays. 2023-03-20 12:10:09 -04:00
Vitor Pamplona abb27ac811 Refactors ChatroomList datasource startup to be more explicit 2023-03-20 09:44:11 -04:00
Vitor PamplonaandGitHub fbf5fb4aab Merge pull request #290 from believethehype/main
simplified HashTagIcon code, new suggestions for #lightning and #zap
2023-03-20 08:59:41 -04:00
Vitor PamplonaandGitHub 3887a6ae44 Merge pull request #291 from Radiokot/fix/actualize_ru_uk_23_03_18
Actualize Russian and Ukrainian translations
2023-03-20 08:45:49 -04:00
Vitor PamplonaandGitHub cfb342cd74 Merge pull request #294 from chiajlingvoj/main
create strings.xml for Eo translation
2023-03-20 08:45:01 -04:00
Vitor Pamplona 2d693145d9 Follows UserAgent version syntax: User-Agent: <product> / <product-version> <comment> 2023-03-20 08:41:03 -04:00
Vitor PamplonaandGitHub d62a2480f4 Merge pull request #296 from hsoc/useragent-ver
User-Agent fix
2023-03-20 08:36:13 -04:00
John Economou b30fb1d9c7 User-Agent fix
include client version in user-agent
2023-03-20 13:43:25 +02:00
chiajlingvojandGitHub fb7c7a723b create strings.xml for Eo translation 2023-03-19 09:50:11 -04:00
Oleg Koretsky fd8517e047 Actualize Russian and Ukrainian translations 2023-03-18 11:25:03 +02:00
Believethehype ab0361bf27 Update zap.xml 2023-03-18 03:39:15 +01:00
Believethehype eb40540f0d Update zap.xml 2023-03-18 03:32:26 +01:00
Believethehype c1f8116a5a simplified HashTagIcon code, new suggestions for #lightning and #zap 2023-03-18 03:12:24 +01:00
Vitor Pamplona 40aadf77f7 Fixing the correct tagging with tags are placed together with additional characters. 2023-03-17 19:25:51 -04:00
Vitor Pamplona adafb6b183 No need for spaces after npubs anymore 2023-03-17 19:25:11 -04:00
Vitor Pamplona d38a7169b8 Fixing spaces after urls 2023-03-17 19:09:43 -04:00
Vitor Pamplona 53705f214a Adding relay icon from https://github.com/vitorpamplona/amethyst/issues/188 2023-03-17 19:09:23 -04:00
Vitor PamplonaandGitHub 155bb80118 Merge pull request #288 from believethehype/main
Fix for hashtag icon resolutions
2023-03-17 18:49:02 -04:00
Believethehype de77f2e18b typo 2023-03-17 23:39:23 +01:00
Vitor Pamplona b2f05b4247 Fixing mutable object to recalculate zap amount. 2023-03-17 18:38:52 -04:00
Believethehype 88292aa179 updated icons 2023-03-17 23:34:59 +01:00
Vitor Pamplona 79ba910f52 Not showing reply information in inner short posts. 2023-03-17 18:01:57 -04:00
Vitor Pamplona 30402b26e8 Removing double space in " and " 2023-03-17 18:01:32 -04:00
Vitor Pamplona cb6401c7ab Don't preview urls without schema. Show inline link instead. 2023-03-17 18:01:08 -04:00
believethehypeandGitHub 435e99e81a Merge branch 'vitorpamplona:main' into main 2023-03-17 22:59:05 +01:00
Believethehype 4b7167d5ef additional zap hts 2023-03-17 22:58:17 +01:00
Believethehype 9887ec2d38 added additional resolutions for hashtagicons, added #zap #lightning #amethyst 2023-03-17 22:53:39 +01:00
Vitor Pamplona edb61e2423 No need for NIP05 in Quoted posts 2023-03-17 17:08:55 -04:00
Vitor Pamplona 8b134892ce Marks PlayStore as default for now 2023-03-17 16:54:57 -04:00
Vitor Pamplona 21cb82a05d v0.027.2 2023-03-17 14:40:35 -04:00
Vitor Pamplona 412c5c557a Adding the suffix after the hashtag icon 2023-03-17 14:06:00 -04:00
Vitor Pamplona 81e31319c7 Making tags case insensitive 2023-03-17 10:22:58 -04:00
Vitor Pamplona 97ec28d2c8 Additional suffix support for hashtags. 2023-03-17 10:09:08 -04:00
Vitor Pamplona c6906c42fd Mocking Android.Log for test classes 2023-03-17 09:47:47 -04:00
Vitor Pamplona 7921db3ba9 Scaling hashtag images 2023-03-17 09:43:09 -04:00
Vitor Pamplona b12413cbff Removing comments from NIP19 Parser 2023-03-17 09:42:54 -04:00
Vitor Pamplona 07cd1738f1 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:30:33 -04:00
Vitor PamplonaandGitHub b8aa7e64b3 Merge pull request #284 from believethehype/main
Added Special Bitcoin Hashtag with Icon
2023-03-17 09:30:06 -04:00
Vitor Pamplona 055ed62cb7 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:29:26 -04:00
Vitor Pamplona 578c70d272 Correctly parsing NIP19s in Text 2023-03-17 09:28:48 -04:00
Vitor PamplonaandGitHub d43f0435d0 Merge pull request #287 from ZsZolee/main
Minor changes
2023-03-17 09:23:12 -04:00
Believethehype 52978fa237 Hashtags in front of a sentence ending should nod work, too. 2023-03-17 13:17:19 +01:00
Believethehype f266a9e964 Special Hashtag for #Nostr 2023-03-17 12:38:47 +01:00
ZoltanandGitHub 01cb27e6f5 Minor changes
Minor changes in the Hungarian translation
2023-03-17 10:12:50 +01:00
Believethehype 38730ea534 fixed some linting issues 2023-03-17 08:03:47 +01:00
believethehypeandGitHub a955f55fc6 Merge branch 'vitorpamplona:main' into main 2023-03-17 07:24:29 +01:00
Believethehype a42cdaaf91 added special Bitcoin Hashtag with Icon
(S/O to tata)
2023-03-16 23:44:25 +01:00
Vitor Pamplona 0633707ec6 Disabling online search after leaving the Search screen 2023-03-16 16:33:17 -04:00
Vitor Pamplona 8768daa06f Fixing Background color of the badge presentation 2023-03-16 16:32:42 -04:00
Vitor Pamplona fb0a7b0ae4 Fixing missing messages on chat 2023-03-16 16:31:42 -04:00
Vitor Pamplona f8b6b8fb45 v0.27.1 2023-03-16 14:24:58 -04:00
Vitor Pamplona 888b6daa2a Support for Following Hashtags 2023-03-16 14:20:30 -04:00
Vitor Pamplona 811c373e4c v0.27.0 2023-03-15 17:07:06 -04:00
Vitor Pamplona 1812ec296e Hashtags 2023-03-15 17:02:49 -04:00
Vitor Pamplona 59cdd81aec Shows LongForm Texts on Global 2023-03-15 17:02:13 -04:00
Vitor Pamplona 9297afc02a Deleting contact lists from pausing the app 2023-03-15 15:13:23 -04:00
Vitor Pamplona a8d8be3307 Removing unused mentions in LocalCache 2023-03-15 14:35:10 -04:00
Vitor Pamplona 53ec6c817c Fixes the deletion of private messages and messages in tags 2023-03-15 14:28:03 -04:00
Vitor Pamplona b7c4ec8358 Shows url host in the link preview 2023-03-15 14:06:26 -04:00
Vitor Pamplona 3426ff307c Makes sure onClick and onLongClick are null when their counterparts are as well. 2023-03-15 13:58:09 -04:00
Vitor Pamplona cddb713b2f Adds the following icon to the chat header 2023-03-15 13:52:26 -04:00
Vitor Pamplona 1871fe6177 Locally cache following Users in the Home & Conversations filter 2023-03-15 13:40:26 -04:00
Vitor Pamplona 83a8558f23 Caching followingChannels and followingUsers to speed Global filter up 2023-03-15 13:39:47 -04:00
Vitor Pamplona dbc155454f Checks for hex keys in the tag list before showing follows 2023-03-15 13:39:06 -04:00
Vitor Pamplona 80e251acfb Avoids creating User for all awardees of a badge. 2023-03-15 13:22:11 -04:00
Vitor Pamplona a597bd7882 Checks the mentions for a valid Hex before creating users in the cache. 2023-03-15 13:14:10 -04:00
Vitor Pamplona a53bb53c12 Avoids breaking urls and invoices when cutting an expandable content 2023-03-15 13:13:41 -04:00
Vitor Pamplona 4fa7aa54a8 Refactoring Note's hasReports function 2023-03-15 13:12:55 -04:00
Vitor Pamplona a9490e1299 Refactoring calls to check if a user is the signed in user. 2023-03-15 13:12:21 -04:00
Vitor Pamplona 2bf36f1cfe Implements a faster AuthorGallery for notifications 2023-03-15 11:39:30 -04:00
Vitor Pamplona c781d5eff7 Forces image size in profile pictures to reduce calculations before resizing the downloaded image. 2023-03-15 11:21:53 -04:00
Vitor Pamplona e8d84795d5 Reduces badge size in Profiles 2023-03-15 11:21:00 -04:00
Vitor Pamplona b351a781a9 Removes time measuring functions from RoboHash 2023-03-15 11:20:36 -04:00
Vitor Pamplona 97d9e96e2b Uses cached functions for isFollowing calculations 2023-03-15 11:20:09 -04:00
Vitor Pamplona 625b8c3bce Makes the reporting Menu less starkey in dark theme 2023-03-15 09:11:55 -04:00
Vitor PamplonaandGitHub edd4a4cc2c Merge pull request #275 from maxmoney21m/feature/216-merge-reports
Add separate screen for report & block
2023-03-15 08:09:07 -04:00
Vitor PamplonaandGitHub b305ccad1d Merge pull request #280 from maxmoney21m/bugfix/gitignore
Remove ignored file
2023-03-15 08:08:39 -04:00
Vitor PamplonaandGitHub 14ca6f1d08 Merge pull request #281 from maxmoney21m/bugfix/string-parameters
Add parameters for strings with variables
2023-03-15 08:08:05 -04:00
maxmoney21m ad4e6d6596 Add Block button and dialog 2023-03-15 13:40:59 +08:00
maxmoney21m e94d2b5340 Update gitignore 2023-03-15 11:29:05 +08:00
maxmoney21m 0241bf0913 Add report & block screen 2023-03-15 11:29:04 +08:00
maxmoney21m 36885eb6dc Add parameters for strings with variables 2023-03-15 11:26:51 +08:00
maxmoney21m c1917855d0 Remove ignored file 2023-03-15 10:42:53 +08:00
138 changed files with 10548 additions and 993 deletions
+1
View File
@@ -68,6 +68,7 @@ render.experimental.xml
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+1
View File
@@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+5 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 98
versionName "0.26.2"
versionCode 106
versionName "0.29.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -38,6 +38,7 @@ android {
productFlavors {
play {
getIsDefault().set(true)
dimension "channel"
}
@@ -103,6 +104,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -77,4 +77,13 @@ class TranslationsTest {
"pt"
)
}
@Test
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en"
)
}
}
@@ -8,6 +8,7 @@ import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
@@ -46,8 +47,10 @@ private object PrefKeys {
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -182,8 +185,10 @@ object LocalPreferences {
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
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_INFO, account.hideDeleteRequestInfo)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
@@ -208,6 +213,15 @@ object LocalPreferences {
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Contact::class.java)
}
} catch (e: Throwable) {
e.printStackTrace()
null
}
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java)
@@ -230,7 +244,8 @@ object LocalPreferences {
mapOf()
}
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -241,7 +256,9 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
hideDeleteRequestInfo,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
)
}
@@ -289,8 +306,8 @@ object LocalPreferences {
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_INFO,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
@@ -71,6 +71,7 @@ object ServiceManager {
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@@ -59,7 +61,9 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var hideDeleteRequestInfo: Boolean = false,
var zapPaymentRequest: Contact? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -90,10 +94,12 @@ class Account(
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
followsTags,
relays,
loggedIn.privKey!!
)
@@ -101,7 +107,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
@@ -160,6 +166,20 @@ class Account(
return null
}
fun hasWalletConnectSetup(): Boolean {
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(lnInvoice: String) {
if (!isWriteable()) return
zapPaymentRequest?.let {
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, loggedIn.privKey!!)
Client.send(event, it.relayUri)
}
}
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user.pubkeyHex)
}
@@ -170,7 +190,7 @@ class Account(
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
@@ -185,7 +205,7 @@ class Account(
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
Client.send(event)
LocalCache.consume(event, null)
}
@@ -245,11 +265,13 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
val event = if (contactList != null) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
@@ -257,6 +279,35 @@ class Account(
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays,
loggedIn.privKey!!
)
@@ -270,11 +321,13 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
@@ -284,10 +337,30 @@ class Account(
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
fun unfollow(tag: String) {
if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex }
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { !it.equals(tag, ignoreCase = true) },
contactList.relays(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = replyTo?.mapNotNull { it.address() }
@@ -296,8 +369,10 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
@@ -359,6 +434,116 @@ class Account(
joinChannel(event.id)
}
fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addPublicBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun removePrivateBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun removePublicBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun isInPrivateBookmarks(note: Note): Boolean {
if (!isWriteable()) return false
if (note is AddressableNote) {
return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!)
?.contains(note.address) == true
} else {
return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!)
?.contains(note.idHex) == true
}
}
fun isInPublicBookmarks(note: Note): Boolean {
if (!isWriteable()) return false
if (note is AddressableNote) {
return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true
} else {
return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true
}
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
live.invalidateData()
@@ -392,6 +577,12 @@ class Account(
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Contact?) {
zapPaymentRequest = newServer
live.invalidateData()
saveable.invalidateData()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
@@ -502,7 +693,11 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet()
return userProfile().cachedFollowingKeySet() ?: emptySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
@@ -553,8 +748,13 @@ class Account(
saveable.invalidateData()
}
fun setHideDeleteRequestInfo() {
hideDeleteRequestInfo = true
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
}
@@ -27,6 +27,10 @@ class Channel(val idHex: String) {
notes[note.idHex] = note
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
}
class HashtagIcon(
val icon: Int,
val description: String,
val color: Color,
val modifier: Modifier
)
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -157,6 +158,18 @@ object LocalCache {
}
}
fun consume(event: BookmarkListEvent) {
val user = getOrCreateUser(event.pubKey)
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
if (event.dTag() == "bookmark") {
user.updateBookmark(event)
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -270,7 +283,6 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardDefinition)
@@ -354,6 +366,19 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
@@ -397,7 +422,6 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
@@ -442,7 +466,7 @@ object LocalCache {
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -526,7 +550,6 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
@@ -698,6 +721,18 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(userAccount: Account) {
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false)) {
it.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -67,7 +67,7 @@ open class Note(val idHex: String) {
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address() = (event as? LongTextNoteEvent)?.address()
open fun address(): ATag? = null
open fun createdAt() = event?.createdAt()
@@ -235,13 +235,42 @@ open class Note(val idHex: String) {
}.sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue = try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() ||
(
author?.reports?.values?.filter {
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
}?.isNotEmpty() ?: false
} ?: false
)
}
@@ -7,8 +7,8 @@ data class RelaySetupInfo(
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
@@ -28,6 +29,7 @@ class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
var notes = setOf<Note>()
private set
@@ -75,6 +77,13 @@ class User(val pubkeyHex: String) {
return info?.picture
}
fun updateBookmark(event: BookmarkListEvent) {
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
}
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
@@ -189,6 +198,7 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
@@ -197,6 +207,15 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
liveSet?.messages?.invalidateData()
}
}
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
@@ -236,7 +255,25 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return (latestContactList)?.unverifiedFollowKeySet()?.toSet()?.let {
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
} ?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
@@ -253,6 +290,10 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
@@ -303,6 +344,7 @@ class UserLiveSet(u: User) {
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val badges: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
fun isInUse(): Boolean {
return follows.hasObservers() ||
@@ -312,7 +354,8 @@ class UserLiveSet(u: User) {
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
badges.hasObservers()
badges.hasObservers() ||
bookmarks.hasObservers()
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -43,7 +44,7 @@ class Nip05Verifier {
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -0,0 +1,31 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.Contact
// Rename to the corect nip number when ready.
object Nip47 {
fun parse(uri: String): Contact? {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex = try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
return Contact(pubkeyHex, relay)
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -51,6 +52,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
@@ -87,7 +99,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter()
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
).ifEmpty { null }
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -8,13 +9,39 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
fun loadMessagesBetween(account: Account, channelId: String) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
fun clear() {
account = null
channel = null
}
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
return TypedFilter(
@@ -32,6 +59,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
messagesChannel.typedFilters = listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter()
).ifEmpty { null }
}
}
@@ -64,7 +64,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 100 // Remember to consider spam that is being removed from the UI
limit = 25 // Remember to consider spam that is being removed from the UI
)
)
}
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -67,6 +68,7 @@ abstract class NostrDataSource(val debugName: String) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is BookmarkListEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
hashtagToWatch = tag
invalidateFilters()
}
}
@@ -58,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}
@@ -18,7 +18,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString == null) {
if (mySearchString.isNullOrBlank()) {
return null
}
@@ -4,10 +4,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -41,7 +43,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -90,6 +92,17 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
)
}
fun createBookmarksFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
@@ -111,7 +124,8 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter()
createReceivedAwardsFilter(),
createBookmarksFilter()
).ifEmpty { null }
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,6 +13,7 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.net.URLEncoder
class LightningAddressResolver {
@@ -52,7 +54,7 @@ class LightningAddressResolver {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -95,7 +97,7 @@ class LightningAddressResolver {
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -128,12 +130,14 @@ class LightningAddressResolver {
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(
lnaddress,
onSuccess = { lnAddressJson ->
onProgress(0.4f)
val lnurlp = try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
@@ -156,6 +160,8 @@ class LightningAddressResolver {
message,
if (allowsNostr) nostrRequest else null,
onSuccess = {
onProgress(0.6f)
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
@@ -164,7 +170,14 @@ class LightningAddressResolver {
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
// Forces LN Invoice amount to be the requested amount.
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
onProgress(0.7f)
onSuccess(pr)
} else {
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
}
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
},
onError = onError
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.service.lnurl
import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
Pattern.CASE_INSENSITIVE
)
/**
* Finds LN withdrawal in the provided input string and returns it.
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
* It will only return the first withdrawal found in the input.
*
* @return the invoice if it was found. null for null input or if no invoice is found
*/
fun findWithdrawal(input: String?): String? {
if (input == null) {
return null
}
val matcher = withdrawalPattern.matcher(input)
return if (matcher.find()) {
matcher.group()
} else {
null
}
}
}
@@ -15,13 +15,6 @@ open class BaseTextNoteEvent(
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
@@ -0,0 +1,117 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 30001
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -33,11 +33,16 @@ class ContactListEvent(
}.toSet()
}
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
@@ -48,6 +53,10 @@ class ContactListEvent(
}
}
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
it.getOrNull(2)
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
@@ -62,7 +71,7 @@ class ContactListEvent(
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
@@ -75,7 +84,10 @@ class ContactListEvent(
} else {
listOf("p", it.pubKeyHex)
}
} + followTags.map {
listOf("t", it)
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
import nostr.postr.toHex
import java.lang.reflect.Type
import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@@ -38,9 +39,31 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
} catch (e: Exception) {
null
}
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
@@ -169,7 +192,7 @@ open class Event(
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
@@ -179,6 +202,7 @@ open class Event(
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
@@ -200,7 +224,15 @@ open class Event(
tags,
content
)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
interface EventInterface {
fun id(): HexKey
@@ -24,4 +25,11 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
}
@@ -23,15 +23,6 @@ class LnZapEvent(
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun taggedAddresses(): List<ATag> = tags
.filter { it.firstOrNull() == "a" }
.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
override fun amount(): BigDecimal? {
return amount
}
@@ -0,0 +1,55 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapPaymentRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun lnInvoice(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 23194
fun create(
lnInvoice: String,
walletServicePubkey: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = Utils.encrypt(
lnInvoice,
privateKey,
walletServicePubkey.toByteArray()
)
val tags = mutableListOf<List<String>>()
tags.add(listOf("p", walletServicePubkey))
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapPaymentRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -15,12 +15,6 @@ class LnZapRequestEvent(
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 9734
@@ -0,0 +1,110 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 10000
fun create(
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): MuteListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val tags = mutableListOf<List<String>>()
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return MuteListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -16,12 +16,6 @@ class ReactionEvent(
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 7
@@ -47,19 +47,16 @@ class ReportEvent(
)
}
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 1984
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
fun create(
reportedPost: EventInterface,
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
@@ -17,12 +17,6 @@ class RepostEvent(
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun containedPost() = try {
fromJson(content, Client.lenient)
@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@@ -17,7 +20,15 @@ class TextNoteEvent(
companion object {
const val kind = 1
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
fun create(
msg: String,
replyTos: List<String>?,
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
@@ -29,9 +40,22 @@ class TextNoteEvent(
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
}
extraTags?.forEach {
tags.add(listOf("t", 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())
}
}
}
fun findURLs(text: String): List<String> {
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
}
@@ -1,35 +1,43 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, RELAY, ADDRESS
USER, NOTE, EVENT, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String, val relay: String? = null)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:") ?: return null
if (uri == null) return null
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
try {
val matcher = nip19regex.matcher(uri)
matcher.find()
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) ?: "" // additional chars
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 $uri: ${e.message}")
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
@@ -68,7 +76,7 @@ object Nip19 {
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
return Return(Type.EVENT, hex, relay)
}
private fun nrelay(bytes: ByteArray): Return? {
@@ -64,8 +64,24 @@ object Client : RelayPool.Listener {
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface) {
RelayPool.send(signedEvent)
fun send(signedEvent: EventInterface, relay: String? = null) {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelay = relays.filter { it.url == relay }
if (useConnectedRelay.isNotEmpty()) {
useConnectedRelay.forEach {
it.send(signedEvent)
}
} else {
/** temporary connection */
Relay(relay, false, true, emptySet()).requestAndWatch() {
it.send(signedEvent)
it.disconnect()
}
}
}
}
fun close(subscriptionId: String) {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
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 okhttp3.OkHttpClient
@@ -30,9 +31,10 @@ class Relay(
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
@@ -52,11 +54,21 @@ class Relay(
@Synchronized
fun requestAndWatch() {
requestAndWatch {
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
if (socket != null) return
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
val listener = object : WebSocketListener() {
@@ -64,12 +76,9 @@ class Relay(
override fun onOpen(webSocket: WebSocket, response: Response) {
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
@@ -81,7 +90,7 @@ class Relay(
when (type) {
"EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
eventDownloadCounterInBytes += text.bytesUsedInMemory()
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
@@ -177,6 +186,7 @@ class Relay(
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
// println("FILTERSSENT ${url} ${request}")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
}
}
} else {
@@ -201,8 +211,9 @@ class Relay(
fun send(signedEvent: EventInterface) {
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
}
@@ -249,3 +260,7 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -34,6 +37,8 @@ class MainActivity : FragmentActivity() {
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
@@ -94,3 +99,13 @@ class MainActivity : FragmentActivity() {
ServiceManager.cleanUp()
}
}
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input).apply {
// Force only images and videos to be selectable
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}
}
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okio.BufferedSource
import okio.IOException
@@ -30,7 +31,7 @@ object ImageSaver {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.header("User-Agent", "Amethyst")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.get()
.url(url)
.build()
@@ -137,5 +138,5 @@ object ImageSaver {
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
private const val PICTURES_SUBDIRECTORY = "Amethyst " + BuildConfig.VERSION_NAME
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
@@ -18,13 +19,16 @@ object ImageUploader {
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
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 client = OkHttpClient.Builder().build()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
category,
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
@@ -44,8 +48,8 @@ object ImageUploader {
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst")
.url("https://api.imgur.com/3/image")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.post(requestBody)
.build()
@@ -14,6 +14,8 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -85,6 +87,8 @@ class NewPostViewModel : ViewModel() {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
@@ -105,6 +109,10 @@ class NewPostViewModel : ViewModel() {
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
@@ -140,12 +148,16 @@ class NewPostViewModel : ViewModel() {
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
@@ -189,7 +201,7 @@ class NewPostViewModel : ViewModel() {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = "@${item.pubkeyNpub()}"
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
@@ -150,27 +150,27 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
Spacer(modifier = Modifier.size(30.dp))
Text(
text = stringResource(R.string.posts),
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(id = R.string.posts),
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.errors),
@@ -371,10 +371,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
text = "${countToHumanReadable(item.downloadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -399,10 +399,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
text = "${countToHumanReadable(item.uploadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -418,7 +418,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -433,7 +433,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -49,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
} else {
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
}
}
}
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -28,6 +27,7 @@ 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.ui.GetMediaActivityResultContract
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@@ -150,7 +150,7 @@ fun GallerySelect(
) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
contract = GetMediaActivityResultContract(),
onResult = { uri: Uri? ->
onImageUri(uri)
hasLaunched.set(false)
@@ -161,7 +161,7 @@ fun GallerySelect(
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
launcher.launch("image/*")
launcher.launch("*/*")
}
}
}
@@ -15,7 +15,7 @@ fun ClickableNoteTag(
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -7,10 +7,12 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@Composable
@@ -24,58 +26,72 @@ fun ClickableRoute(
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@$text "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
} else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
if (noteBase == null) {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.NOTE) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.EVENT) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
}
} else {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("@$clickablePart")
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) {
append("$suffix ")
}
},
onClick = { navController.navigate(route) }
)
}
@@ -12,7 +12,7 @@ fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
ClickableText(
text = AnnotatedString("$urlText "),
text = AnnotatedString(urlText),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -17,7 +17,7 @@ fun ClickableUserTag(
) {
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.core.content.ContextCompat
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$withdrawalString "),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
ContextCompat.startActivity(context, intent, null)
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
const val SHORT_TEXT_LENGTH = 350
@@ -43,15 +42,14 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH)
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut
val text = if (showFullText) {
content
} else {
val (lnStart, lnEnd) = LnInvoiceUtil.locateInvoice(content)
if (lnStart < SHORT_TEXT_LENGTH && lnEnd > 0) {
content.take(lnEnd)
} else {
content.take(SHORT_TEXT_LENGTH)
}
content.take(whereToCut)
}
Box(contentAlignment = Alignment.BottomCenter) {
@@ -67,7 +65,7 @@ fun ExpandableRichTextViewer(
)
// }
if (content.length > 350 && !showFullText) {
if (content.length > whereToCut && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
@@ -149,6 +149,8 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
},
onProgress = {
}
)
},
@@ -1,13 +1,16 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -17,8 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
@@ -32,7 +35,9 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -41,13 +46,16 @@ import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\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 urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -61,6 +69,8 @@ fun isValidURL(url: String?): Boolean {
}
}
val richTextDefaults = RichTextStyle().resolveDefaults()
@Composable
fun RichTextViewer(
content: String,
@@ -71,8 +81,8 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
@@ -86,9 +96,13 @@ fun RichTextViewer(
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
@@ -117,6 +131,20 @@ fun RichTextViewer(
)
}
} else {
val imagesForPager = mutableListOf<String>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
// sequence of images will render in a slideview
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
imagesForPager.add(word)
}
}
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
@@ -125,25 +153,37 @@ fun RichTextViewer(
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
ZoomableImageView(word, imagesForPager)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
UrlPreview(word, "$word ")
}
} else if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -160,9 +200,17 @@ fun RichTextViewer(
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -191,19 +239,7 @@ fun isBechLink(word: String): Boolean {
@Composable
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:$word"
}
val nip19Route = try {
Nip19.uriToRoute(uri)
} catch (e: Exception) {
null
}
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
@@ -212,16 +248,85 @@ fun BechLink(word: String, navController: NavController) {
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (tag, suffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
val (index, extraCharacters) = try {
matcher.find()
matcher.group(1)?.toInt()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
println("Couldn't link tag $word")
null
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (index == null) {
@@ -236,6 +341,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
val user = userState.value?.user
if (user != null) {
ClickableUserTag(user, navController)
Text(text = "$extraCharacters ")
} else {
Text(text = "$word ")
}
@@ -251,7 +357,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(0.dp)
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -266,6 +372,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
)
} else {
ClickableNoteTag(note, navController)
Text(text = "$extraCharacters ")
}
} else {
// if here the tag is not a valid Nostr Hex
@@ -13,8 +13,6 @@ import coil.request.ImageRequest
import coil.request.Options
import okio.Buffer
import java.security.MessageDigest
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
private fun toHex(color: Color): String {
val argb = color.toArgb()
@@ -66,26 +64,20 @@ class HashImageFetcher(
private val data: Uri
) : Fetcher {
@OptIn(ExperimentalTime::class)
override suspend fun fetch(): SourceResult {
val (value, elapsed) = measureTimedValue {
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
println("Elapsed: $elapsed")
return value
return SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
class Factory : Fetcher.Factory<Uri> {
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
}
@@ -96,7 +88,7 @@ object Robohash {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory())
.fetcherFactory(HashImageFetcher.Factory)
.crossfade(100)
.build()
}
@@ -0,0 +1,104 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
@OptIn(ExperimentalPagerApi::class)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
pagerState: PagerState = remember { PagerState() },
itemsCount: Int,
itemContent: @Composable (index: Int) -> Unit
) {
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(count = itemsCount, state = pagerState) { page ->
itemContent(page)
}
// you can remove the surface in case you don't want
// the transparant bacground
Surface(
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.BottomCenter),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.5f)
) {
DotsIndicator(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
totalDots = itemsCount,
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
dotSize = 8.dp
)
}
}
}
@Composable
fun DotsIndicator(
modifier: Modifier = Modifier,
totalDots: Int,
selectedIndex: Int,
selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */,
unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */,
dotSize: Dp
) {
LazyRow(
modifier = modifier
.wrapContentWidth()
.wrapContentHeight()
) {
items(totalDots) { index ->
IndicatorDot(
color = if (index == selectedIndex) selectedColor else unSelectedColor,
size = dotSize
)
if (index != totalDots - 1) {
Spacer(modifier = Modifier.padding(horizontal = 2.dp))
}
}
}
}
@Composable
fun IndicatorDot(
modifier: Modifier = Modifier,
size: Dp,
color: Color
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color)
)
}
@@ -0,0 +1,101 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.BorderStroke
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier
) {
OutlinedTextField(
value = currentText,
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
focusRequester.requestFocus()
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp)
) {
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
.clickable {
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
}
}
}
}
}
}
@@ -34,12 +34,18 @@ fun UrlPreviewCard(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
val url = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(URL(previewInfo.url), previewInfo.image).toString()
URL(url, previewInfo.image).toString()
} else {
previewInfo.image
}
@@ -51,12 +57,23 @@ fun UrlPreviewCard(
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -1,61 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale *= event.calculateZoom()
val offset = event.calculatePan()
offsetX += offset.x
offsetY += offset.y
} while (event.changes.any { it.pressed })
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
)
)
}
}
@@ -18,22 +18,27 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -49,7 +54,11 @@ fun ZoomableImageView(word: String) {
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
@@ -57,12 +66,13 @@ fun ZoomableImageView(word: String) {
)
if (dialogOpen) {
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -71,6 +81,8 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
Column(
modifier = Modifier.padding(10.dp)
) {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.fillMaxWidth(),
@@ -79,10 +91,34 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = imageUrl)
SaveToGallery(url = allImages[pagerState.currentPage])
}
ZoomableAsyncImage(imageUrl)
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
AsyncImage(
model = allImages[index],
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
}
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
}
}
}
}
@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val bookmarks = account.userProfile().latestBookmarkList
val notes = bookmarks?.privateTaggedEvents(privKey)
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.privateTaggedAddresses(privKey)
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -0,0 +1,20 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
val notes = bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -10,20 +10,41 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels()) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in account.followingKeySet())
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val followUsers = account.followingKeySet()
val notes = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
val longFormNotes = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -0,0 +1,39 @@
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.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
return LocalCache.notes.values
.asSequence()
.filter {
(
it.event is TextNoteEvent ||
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent
) &&
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
}
@@ -3,7 +3,6 @@ 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.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
@@ -11,13 +10,15 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.event is TextNoteEvent) &&
(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 { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
.sortedBy { it.createdAt() }
@@ -12,11 +12,13 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.event is TextNoteEvent || it.event is RepostEvent) &&
(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) } ?: true &&
it.isNewThread()
@@ -24,8 +26,8 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.event is LongTextNoteEvent) &&
(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) } ?: true &&
it.isNewThread()
@@ -0,0 +1,31 @@
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.model.User
object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.users[userId]
}
override fun feed(): List<Note> {
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
LocalCache.checkGetOrCreateNote(it)
}?.toSet() ?: emptySet()
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
LocalCache.getOrCreateAddressableNote(it)
}?.toSet() ?: emptySet()
return (notes + addresses)
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -14,8 +14,8 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.latestContactList?.unverifiedFollowKeySet()?.map {
LocalCache.getOrCreateUser(it)
return user?.latestContactList?.unverifiedFollowKeySet()?.mapNotNull {
LocalCache.checkGetOrCreateUser(it)
}?.toSet()
?.filter { account.isAcceptable(it) }
?.reversed() ?: emptyList()
@@ -16,11 +16,14 @@ 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.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
@@ -72,7 +75,8 @@ fun AppNavigation(
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) })
composable(Route.Notification.route, content = { NotificationScreen(accountViewModel, navController) })
composable(Route.Filters.route, content = { FiltersScreen(accountViewModel, navController) })
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) })
Route.Profile.let { route ->
composable(route.route, route.arguments, content = {
@@ -94,6 +98,16 @@ fun AppNavigation(
})
}
Route.Hashtag.let { route ->
composable(route.route, route.arguments, content = {
HashtagScreen(
tag = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
}
Route.Room.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreen(
@@ -113,6 +127,16 @@ fun AppNavigation(
)
})
}
Route.Event.let { route ->
composable(route.route, route.arguments, content = {
LoadRedirectScreen(
eventId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
}
}
if (nextPage != null) {
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
@@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrSingleChannelDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
@@ -212,7 +214,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
modifier = Modifier
) {
Icon(
painter = painterResource(R.drawable.ic_trends),
painter = painterResource(R.drawable.relays),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
@@ -232,21 +232,26 @@ fun ListContent(
scaffoldState = scaffoldState,
route = "User/${accountUser.pubkeyHex}"
)
}
Divider(thickness = 0.25.dp)
NavigationRow(
title = stringResource(R.string.bookmarks),
icon = Route.Bookmarks.icon,
tint = MaterialTheme.colors.onBackground,
navController = navController,
scaffoldState = scaffoldState,
route = Route.Bookmarks.route
)
}
NavigationRow(
title = stringResource(R.string.security_filters),
icon = Route.Filters.icon,
icon = Route.BlockedUsers.icon,
tint = MaterialTheme.colors.onBackground,
navController = navController,
scaffoldState = scaffoldState,
route = Route.Filters.route
route = Route.BlockedUsers.route
)
Divider(thickness = 0.25.dp)
IconRow(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
@@ -48,11 +48,16 @@ sealed class Route(
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
)
object Filters : Route(
route = "Filters",
object BlockedUsers : Route(
route = "BlockedUsers",
icon = R.drawable.ic_security
)
object Bookmarks : Route(
route = "Bookmarks",
icon = R.drawable.ic_bookmarks
)
object Profile : Route(
route = "User/{id}",
icon = R.drawable.ic_profile,
@@ -65,6 +70,12 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Hashtag : Route(
route = "Hashtag/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,
@@ -76,6 +87,12 @@ sealed class Route(
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Event : Route(
route = "Event/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
}
// **
@@ -0,0 +1,144 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.PostButton
class AddBountyAmountViewModel : ViewModel() {
private var account: Account? = null
private var bounty: Note? = null
var nextAmount by mutableStateOf(TextFieldValue(""))
fun load(account: Account, bounty: Note?) {
this.account = account
this.bounty = bounty
}
fun sendPost() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null) {
account?.sendPost(
newValue.toString(),
listOfNotNull(bounty),
listOfNotNull(bounty?.author),
listOf("bounty-added-reward")
)
nextAmount = TextFieldValue("")
}
}
fun cancel() {
nextAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
return nextAmount.text.trim().toLongOrNull() != null
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddBountyAmountDialog(bounty: Note, account: Account, onClose: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: AddBountyAmountViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account, bounty)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "10000, 50000, 5000000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}
}
@@ -98,14 +98,16 @@ fun ChatroomMessageCompose(
if (note?.event == null) {
BlankNote(Modifier)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
innerQuote,
navController,
onClick = { showHiddenNote = true }
)
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
innerQuote,
navController,
onClick = { showHiddenNote = true }
)
}
} else {
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
@@ -277,7 +279,7 @@ fun ChatroomMessageCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == accountUser ||
(note.author?.let { accountUser.isFollowing(it) } ?: true) ||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -344,7 +346,7 @@ fun ChatroomMessageCompose(
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -363,7 +365,7 @@ private fun RelayBadges(baseNote: Note) {
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
@@ -26,11 +26,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -114,18 +118,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
}
}
@@ -146,18 +139,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.boostEvents, navController, account)
}
}
@@ -178,18 +160,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.likeEvents, navController, account)
}
}
@@ -218,3 +189,54 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
}
}
}
@Composable
fun FastNoteAuthorPicture(
note: Note,
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
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = pictureModifier,
onClick = {
navController.navigate("User/${user.pubkeyHex}")
}
)
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -76,18 +77,18 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
}
@Composable
fun ObserveDisplayNip05Status(baseNote: Note) {
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
if (author != null) {
ObserveDisplayNip05Status(author)
ObserveDisplayNip05Status(author, columnModifier)
}
}
@Composable
fun ObserveDisplayNip05Status(baseUser: User) {
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
@@ -96,52 +97,54 @@ fun ObserveDisplayNip05Status(baseUser: User) {
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Visible
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
}
}
@@ -1,15 +1,19 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@@ -19,6 +23,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -32,11 +37,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -45,6 +53,7 @@ import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -59,9 +68,12 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -106,14 +118,16 @@ fun NoteCompose(
isBoostedNote
)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
}
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) {
@@ -265,6 +279,8 @@ fun NoteCompose(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
}
Text(
@@ -288,17 +304,24 @@ fun NoteCompose(
}
}
if (note.author != null && !makeItShort) {
ObserveDisplayNip05Status(note.author!!)
if (note.author != null && !makeItShort && !isQuotedNote) {
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
val baseReward = noteEvent.getReward()
if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController)
}
}
}
Spacer(modifier = Modifier.height(3.dp))
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
@@ -323,11 +346,11 @@ fun NoteCompose(
} else {
ReplyInformation(note.replyTo, sortedMentions, account, navController)
}
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
.sortedBy { account.userProfile().isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
@@ -377,7 +400,7 @@ fun NoteCompose(
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
LongFormHeader(noteEvent)
LongFormHeader(noteEvent, note, account.userProfile())
ReactionsRow(note, accountViewModel)
@@ -452,19 +475,30 @@ fun NoteCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
if (makeItShort && note.author == account.userProfile()) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
TranslateableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
}
if (!makeItShort) {
@@ -484,19 +518,146 @@ fun NoteCompose(
}
}
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
account: Account,
navController: NavController
) {
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
val firstTag =
noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
@Composable
fun DisplayUncitedHashtags(
noteEvent: EventInterface,
eventContent: String,
navController: NavController
) {
val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
) {
hashtags.forEach { hashtag ->
if (!eventContent.contains(hashtag, true)) {
ClickableText(
text = AnnotatedString("#$hashtag "),
onClick = { navController.navigate("Hashtag/$hashtag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
}
@Composable
fun DisplayReward(
baseReward: BigDecimal,
baseNote: Note,
account: Account,
navController: NavController
) {
var popupExpanded by remember { mutableStateOf(false) }
Column() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { popupExpanded = true }
) {
ClickableText(
text = AnnotatedString("#bounty"),
onClick = { navController.navigate("Hashtag/bounty") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
val repliesState by baseNote.live().replies.observeAsState()
val replyNote = repliesState?.note
if (replyNote?.hasPledgeBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
var rewardAmount by remember {
mutableStateOf<BigDecimal?>(
baseReward
)
}
LaunchedEffect(key1 = repliesState) {
withContext(Dispatchers.IO) {
replyNote?.pledgedAmountByOthers()?.let {
rewardAmount = baseReward.add(it)
}
}
}
Text(
showAmount(rewardAmount),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
if (popupExpanded) {
AddBountyAmountDialog(baseNote, account) {
popupExpanded = false
}
}
}
}
@Composable
fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
var backgroundFromImage by remember { mutableStateOf(background) }
Row(
modifier = Modifier
.padding(10.dp)
.clip(shape = CutCornerShape(20, 20, 0, 0))
.clip(shape = CutCornerShape(20, 20, 20, 20))
.border(
5.dp,
MaterialTheme.colors.primary.copy(alpha = 0.32f),
CutCornerShape(20)
)
.background(backgroundFromImage)
) {
Column {
badgeData.image()?.let {
@@ -507,7 +668,11 @@ fun BadgeDisplay(baseNote: Note) {
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
onSuccess = {
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
backgroundFromImage = Color(backgroundColor)
}
)
}
@@ -518,7 +683,8 @@ fun BadgeDisplay(baseNote: Note) {
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.padding(start = 10.dp, end = 10.dp),
color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
)
}
@@ -540,7 +706,7 @@ fun BadgeDisplay(baseNote: Note) {
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
@@ -561,6 +727,35 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: Box() {
note.author?.info?.banner?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Box(
Modifier
.width(75.dp)
.height(75.dp)
.padding(10.dp)
.align(Alignment.BottomStart)
) {
NoteAuthorPicture(baseNote = note, baseUserAccount = loggedIn, size = 55.dp)
}
}
noteEvent.title()?.let {
@@ -585,6 +780,16 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
overflow = TextOverflow.Ellipsis
)
}
?: Text(
text = noteEvent.content.take(200),
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -682,7 +887,8 @@ fun NoteAuthorPicture(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
@@ -705,7 +911,6 @@ fun UserPicture(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
baseUser: User,
@@ -718,24 +923,52 @@ fun UserPicture(
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = modifier,
onClick = onClick?.let { { it(user) } },
onLongClick = onLongClick?.let { { it(user) } }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
userHex: String,
userPicture: String?,
showFollowingMark: Boolean,
size: Dp,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
Box(
Modifier
.width(size)
.height(size)
) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), size),
robot = userHex,
model = ResizeImage(userPicture, size),
contentDescription = stringResource(id = R.string.profile_image),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null && onLongClick != null) {
this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) })
this.combinedClickable(onClick = onClick, onLongClick = onLongClick)
} else if (onClick != null) {
this.clickable(onClick = { onClick(user) })
this.clickable(onClick = onClick)
} else {
this
}
@@ -743,10 +976,7 @@ fun UserPicture(
)
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
if (showFollowingMark) {
Box(
Modifier
.width(size.div(3.5f))
@@ -779,12 +1009,13 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()!!.isFollowing(note.author!!)) {
if (!accountViewModel.isFollowing(note.author)) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -821,60 +1052,44 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (accountViewModel.isInPrivateBookmarks(note)) {
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (accountViewModel.isInPublicBookmarks(note)) {
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text(stringResource(R.string.broadcast))
}
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
Divider()
if (accountViewModel.isLoggedUser(note.author)) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}
}
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
DropdownMenuItem(onClick = {
note.author?.let {
accountViewModel.hide(it)
}; onDismiss()
}) {
Text(stringResource(R.string.block_hide_user))
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.NUDITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_illegal_behaviour))
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text("Block / Report")
}
}
}
if (reportDialogShowing) {
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
reportDialogShowing = false
onDismiss()
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,6 +17,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
@@ -24,11 +27,13 @@ import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
@@ -54,12 +60,15 @@ import androidx.compose.ui.window.Popup
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.launch
fun lightenColor(color: Color, amount: Float): Color {
private fun lightenColor(color: Color, amount: Float): Color {
var argb = color.toArgb()
val hslOut = floatArrayOf(0f, 0f, 0f)
ColorUtils.colorToHSL(argb, hslOut)
@@ -68,10 +77,20 @@ fun lightenColor(color: Color, amount: Float): Color {
return Color(argb)
}
val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" }
val externalLinkForNote = { note: Note ->
if (note is AddressableNote) {
if (note.event?.getReward() != null) {
"https://nostrbounties.com/b/${note.address().toNAddr()}"
} else {
"https://habla.news/a/${note.address().toNAddr()}"
}
} else {
"https://snort.social/e/${note.idNote()}"
}
}
@Composable
fun VerticalDivider(color: Color) =
private fun VerticalDivider(color: Color) =
Divider(
color = color,
modifier = Modifier
@@ -88,9 +107,17 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
val scope = rememberCoroutineScope()
var showSelectTextDialog by remember { mutableStateOf(false) }
var showDeleteAlertDialog by remember { mutableStateOf(false) }
val isOwnNote = note.author == accountViewModel.userProfile()
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
@@ -106,7 +133,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = MaterialTheme.colors.primary
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
@@ -134,6 +161,19 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
} else {
showBlockAlertDialog = true
}
}
}
}
Divider(
color = primaryLight,
@@ -144,7 +184,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestInfo()) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
} else {
@@ -187,6 +227,14 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
showReportDialog = true
}
}
}
}
}
@@ -200,36 +248,24 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (showDeleteAlertDialog) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_title))
},
text = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_body))
},
buttons = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = {
accountViewModel.setHideDeleteRequestInfo()
accountViewModel.delete(note)
onDismiss()
}
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(
onClick = { accountViewModel.delete(note); onDismiss() }
) {
Text(stringResource(R.string.quick_action_delete_button))
}
}
}
)
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog = false
onDismiss()
}
}
if (showBlockAlertDialog) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog = false
onDismiss()
}
}
if (showReportDialog) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog = false
onDismiss()
}
}
}
@@ -245,9 +281,99 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
modifier = Modifier
.size(24.dp)
.padding(bottom = 5.dp),
tint = Color.White
)
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.delete(note)
onDismiss()
},
onClickDontShowAgain = {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.report_dialog_block_hide_user_btn),
textContent = stringResource(R.string.report_dialog_blocking_a_user),
buttonIcon = Icons.Default.Block,
buttonText = stringResource(R.string.quick_action_block_dialog_btn),
buttonColors = ButtonDefaults.buttonColors(
backgroundColor = WarningColor,
contentColor = Color.White
),
onClickDoOnce = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
onClickDontShowAgain = {
note.author?.let { accountViewModel.hide(it) }
accountViewModel.dontShowBlockAlertDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun QuickActionAlertDialog(
title: String,
textContent: String,
buttonIcon: ImageVector,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickDontShowAgain) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(onClick = onClickDoOnce, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = buttonIcon,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(buttonText)
}
}
}
}
)
}
@@ -1,12 +1,10 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -15,15 +13,14 @@ 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.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
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.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
@@ -39,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@@ -47,27 +45,17 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
@@ -323,7 +311,10 @@ fun ZapReaction(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.then(Modifier.size(20.dp))
.combinedClickable(
@@ -352,17 +343,26 @@ fun ZapReaction(
.show()
}
} else if (account.zapAmountChoices.size == 1) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
"",
context
) {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
"",
context,
onError = {
scope.launch {
zappingProgress = 0f
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
)
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
@@ -379,6 +379,7 @@ fun ZapReaction(
accountViewModel,
onDismiss = {
wantsToZap = false
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = false
@@ -386,8 +387,14 @@ fun ZapReaction(
},
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
)
}
@@ -396,6 +403,7 @@ fun ZapReaction(
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -403,18 +411,26 @@ fun ZapReaction(
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = grayTint
)
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(16.dp)
)
}
}
}
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zappedNote) {
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = zappedNote?.zappedAmount()
}
@@ -501,12 +517,21 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit) {
fun ZapAmountChoicePopup(
baseNote: Note,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val context = LocalContext.current
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val scope = rememberCoroutineScope()
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
@@ -517,8 +542,17 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -532,8 +566,17 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
onLongClick = {
onChangeAmount()
@@ -546,153 +589,6 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
}
}
class UpdateZapAmountViewModel : ViewModel() {
private var account: Account? = null
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
fun load(account: Account) {
this.account = account
this.amountSet = account.zapAmountChoices
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
}
fun addAmount() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null) {
amountSet = amountSet + newValue
}
nextAmount = TextFieldValue("")
}
fun removeAmount(amount: Long) {
amountSet = amountSet - amount
}
fun sendPost() {
account?.changeZapAmounts(amountSet)
nextAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
return amountSet != account?.zapAmountChoices
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val postViewModel: UpdateZapAmountViewModel = viewModel()
// initialize focus reference to be able to request focus programmatically
// val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(account) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
SaveButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged()
)
}
Spacer(modifier = Modifier.height(10.dp))
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)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(), 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)
}
}
}
}
}
}
fun showCount(count: Int?): String {
if (count == null) return ""
if (count == 0) return ""
@@ -30,7 +30,7 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Accou
@Composable
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) }
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowingCached(it) }
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
FlowRow() {
@@ -64,7 +64,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
} else if (idx < repliesToDisplay.size - 1) {
Text(
" ${stringResource(R.string.and)} ",
"${stringResource(R.string.and)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -78,7 +78,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
} else if (idx < repliesToDisplay.size) {
Text(
" ${stringResource(R.string.and)} ",
"${stringResource(R.string.and)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -0,0 +1,331 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
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.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import kotlinx.coroutines.launch
class UpdateZapAmountViewModel : ViewModel() {
private var account: Account? = null
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
this.account = account
this.amountSet = account.zapAmountChoices
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
}
fun addAmount() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null) {
amountSet = amountSet + newValue
}
nextAmount = TextFieldValue("")
}
fun removeAmount(amount: Long) {
amountSet = amountSet - amount
}
fun sendPost() {
account?.changeZapAmounts(amountSet)
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
account?.changeZapPaymentRequest(Contact(walletConnectPubkey.text, walletConnectRelay.text))
} else {
account?.changeZapPaymentRequest(null)
}
nextAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
return (
amountSet != account?.zapAmountChoices ||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "")
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: UpdateZapAmountViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
SaveButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged()
)
}
Spacer(modifier = Modifier.height(10.dp))
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
)
}
}
}
}
}
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 = {
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 {
val contact = Nip47.parse(it)
if (contact != null) {
postViewModel.walletConnectPubkey = TextFieldValue(contact.pubKeyHex)
postViewModel.walletConnectRelay = TextFieldValue(contact.relayUri ?: "")
}
} 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 = "relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
}
}
}
}
@@ -72,7 +72,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (userFollows.isFollowing(baseUser)) {
} else if (userFollows.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
@@ -117,7 +117,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
}
} else if (userFollows.isFollowing(baseAuthor)) {
} else if (userFollows.isFollowingCached(baseAuthor)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.qrcode
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -12,15 +13,15 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.nip19.Nip19
@Composable
fun QrCodeScanner(onScan: (String?) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val parseQrResult = { it: String ->
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
SimpleQrCodeScanner {
try {
val nip19 = Nip19.uriToRoute(it)
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
@@ -30,15 +31,21 @@ fun QrCodeScanner(onScan: (String?) -> Unit) {
onScan(null)
}
} catch (e: Throwable) {
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors.
onScan(null)
}
}
}
@Composable
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val qrLauncher =
rememberLauncherForActivityResult(ScanContract()) {
if (it.contents != null) {
parseQrResult(it.contents)
onScan(it.contents)
} else {
onScan(null)
}
@@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
@Composable
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
@@ -135,7 +135,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
}
}
} else {
QrCodeScanner {
NIP19QrCodeScanner {
if (it.isNullOrEmpty()) {
presenting = true
} else {
@@ -99,8 +99,6 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
// val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map {
MultiSetCard(
@@ -5,15 +5,19 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
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
@@ -33,14 +37,19 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter)
class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter)
class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter)
class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter)
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -57,6 +57,9 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.DisplayReward
import com.vitorpamplona.amethyst.ui.note.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -250,6 +253,8 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -271,7 +276,14 @@ fun NoteMaster(
}
}
ObserveDisplayNip05Status(baseNote)
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, Modifier.weight(1f))
val baseReward = noteEvent.getReward()
if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController)
}
}
}
}
@@ -328,7 +340,7 @@ fun NoteMaster(
val eventContent = note.event?.content()
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -341,6 +353,8 @@ fun NoteMaster(
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
ReactionsRow(note, accountViewModel)
@@ -7,6 +7,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
@@ -14,6 +15,9 @@ 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.ReportEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Locale
class AccountViewModel(private val account: Account) : ViewModel() {
@@ -48,7 +52,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) {
suspend fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
@@ -58,23 +62,39 @@ class AccountViewModel(private val account: Account) : ViewModel() {
val zapRequest = account.createZapRequestFor(note)
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it)
onProgress(0.8f)
// Awaits for the event to come back to LocalCache.
viewModelScope.launch(Dispatchers.IO) {
delay(1000)
onProgress(0f)
}
} else {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
onProgress(0f)
}
},
onError = onError
onError = onError,
onProgress = onProgress
)
}
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
account.report(note, type, content)
}
fun report(user: User, type: ReportEvent.ReportType) {
@@ -85,6 +105,30 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.boost(note)
}
fun addPrivateBookmark(note: Note) {
account.addPrivateBookmark(note)
}
fun addPublicBookmark(note: Note) {
account.addPublicBookmark(note)
}
fun removePrivateBookmark(note: Note) {
account.removePrivateBookmark(note)
}
fun removePublicBookmark(note: Note) {
account.removePublicBookmark(note)
}
fun isInPrivateBookmarks(note: Note): Boolean {
return account.isInPrivateBookmarks(note)
}
fun isInPublicBookmarks(note: Note): Boolean {
return account.isInPublicBookmarks(note)
}
fun broadcast(note: Note) {
account.broadcast(note)
}
@@ -125,15 +169,26 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.unfollow(user)
}
fun isFollowing(user: User): Boolean {
return account.userProfile().isFollowing(user)
fun isLoggedUser(user: User?): Boolean {
return account.userProfile() == user
}
fun hideDeleteRequestInfo(): Boolean {
return account.hideDeleteRequestInfo
fun isFollowing(user: User?): Boolean {
if (user == null) return false
return account.userProfile().isFollowingCached(user)
}
fun setHideDeleteRequestInfo() {
account.setHideDeleteRequestInfo()
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
fun dontShowDeleteRequestDialog() {
account.setHideDeleteRequestDialog()
}
val hideBlockAlertDialog: Boolean
get() = account.hideBlockAlertDialog
fun dontShowBlockAlertDialog() {
account.setHideBlockAlertDialog()
}
}
@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null) {
BookmarkPublicFeedFilter.account = account
BookmarkPrivateFeedFilter.account = account
val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = viewModel()
val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = viewModel()
val userState by account.userProfile().live().bookmarks.observeAsState()
LaunchedEffect(userState) {
publicFeedViewModel.invalidateData()
privateFeedViewModel.invalidateData()
}
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = stringResource(R.string.private_bookmarks))
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = stringResource(R.string.public_bookmarks))
}
)
}
HorizontalPager(count = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
}
}
}
}
}
}
@@ -92,7 +92,7 @@ fun ChannelScreen(
val replyTo = remember { mutableStateOf<Note?>(null) }
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return
@@ -101,6 +101,9 @@ fun ChannelScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
feedViewModel.invalidateData()
channelScreenModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
@@ -111,11 +114,15 @@ fun ChannelScreen(
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
NostrChannelDataSource.start()
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Channel Stop")
NostrChannelDataSource.clear()
NostrChannelDataSource.stop()
}
}
@@ -139,7 +139,8 @@ fun TabKnown(
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
feedViewModel.refresh()
}
@@ -147,7 +148,8 @@ fun TabKnown(
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.resetFilters()
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
feedViewModel.refresh()
}
}
@@ -180,7 +182,8 @@ fun TabNew(
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
feedViewModel.refresh()
}
@@ -188,7 +191,8 @@ fun TabNew(
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.resetFilters()
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
feedViewModel.refresh()
}
}
@@ -12,8 +12,6 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -35,7 +33,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -56,10 +53,9 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
@@ -108,7 +104,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(Modifier.fillMaxHeight()) {
NostrChatroomDataSource.withUser?.let {
ChatroomHeader(it, navController = navController)
ChatroomHeader(it, account.userProfile(), navController = navController)
}
Column(
@@ -210,7 +206,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
}
@Composable
fun ChatroomHeader(baseUser: User, navController: NavController) {
fun ChatroomHeader(baseUser: User, accountUser: User, navController: NavController) {
Column(
modifier = Modifier.clickable(
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
@@ -218,17 +214,10 @@ fun ChatroomHeader(baseUser: User, navController: NavController) {
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.live().metadata.observeAsState()
val author = authorState?.user!!
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = ResizeImage(author.profilePicture(), 35.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
UserPicture(
baseUser = baseUser,
baseUserAccount = accountUser,
size = 35.dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
@@ -0,0 +1,125 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val lifeCycleOwner = LocalLifecycleOwner.current
if (tag != null) {
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
LaunchedEffect(tag) {
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hashtag Stop")
HashtagFeedFilter.loadHashtag(account, null)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
}
@Composable
fun HashtagHeader(tag: String, account: Account) {
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val coroutineScope = rememberCoroutineScope()
Column() {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"#$tag",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
if (userFollows.isFollowingHashtagCached(tag)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
}
}
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}
@@ -29,7 +29,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) {
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@Composable
fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, navController: NavController) {
if (eventId == null) return
val baseNote = LocalCache.checkGetOrCreateNote(eventId) ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
LaunchedEffect(key1 = noteState) {
val event = note?.event
val channel = note?.channel()
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
navController.backQueue.removeLast()
navController.navigate("Channel/${note.idHex}")
} else if (event is PrivateDmEvent) {
navController.backQueue.removeLast()
navController.navigate("Room/${note.author?.pubkeyHex}")
} else if (channel != null) {
navController.backQueue.removeLast()
navController.navigate("Channel/${channel.idHex}")
} else {
navController.backQueue.removeLast()
navController.navigate("Note/${note.idHex}")
}
}
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth()
.padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.looking_for_event, eventId))
}
}

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