Compare commits

...
133 Commits
Author SHA1 Message Date
Vitor Pamplona 79e3450a46 v0.34.0 2023-04-21 17:07:07 -04:00
Vitor Pamplona 783204b57f Support for NIP-94 2023-04-21 17:01:42 -04:00
Vitor Pamplona 1adb2b2caa Fix crashing when changing users. 2023-04-21 11:00:06 -04:00
Vitor Pamplona 3f579793ca v0.33.2 2023-04-21 10:21:50 -04:00
Vitor Pamplona 8040608e14 Fixes: https://github.com/vitorpamplona/amethyst/issues/359 2023-04-21 10:21:24 -04:00
Vitor PamplonaandGitHub 2e66d278ad Merge pull request #362 from vivganes/patch-361
Show clickable phone number and nostr:npub reference in markdown notes
2023-04-21 08:48:50 -04:00
Vitor PamplonaandGitHub 1bfbf3643d Merge branch 'main' into patch-361 2023-04-21 08:48:41 -04:00
Vitor PamplonaandGitHub 5990bf0213 Merge pull request #360 from Bardesss/patch-3
Update strings.xml (NL)
2023-04-21 08:44:33 -04:00
vivganes 5e3db99675 fix #361 2023-04-21 17:20:50 +05:30
BardesssandGitHub a291741c37 Update strings.xml 2023-04-21 09:06:54 +00:00
BardesssandGitHub 94ee80d8ea Update strings.xml 2023-04-21 08:59:36 +00:00
BardesssandGitHub f7de66c6a5 Update strings.xml (NL)
Added missing strings.
2023-04-21 07:34:42 +00:00
Vitor Pamplona 48d890b354 v0.33.1 2023-04-20 17:24:33 -04:00
Vitor Pamplona c7a9bd1226 Using FlowRow to display relay icons. 2023-04-20 17:19:34 -04:00
Vitor Pamplona c40c7bc62b Removing LazyGrid from reactions Row due to being slower than the Row itself. 2023-04-20 17:19:20 -04:00
Vitor Pamplona cca7a10322 Caching images that cause errors and avoid calling them again. 2023-04-20 17:18:53 -04:00
Vitor Pamplona fe6ef1df0a Migration to Gradle 8.0 2023-04-20 17:18:20 -04:00
Vitor Pamplona 3e27567532 Moves home and Notification bubbles to the adaptive feed. 2023-04-20 14:09:26 -04:00
Vitor Pamplona 0a15e079dc Compares room messages by reference. 2023-04-20 14:08:58 -04:00
Vitor Pamplona 48f4fc4a0f v0.33.0 2023-04-20 12:11:58 -04:00
Vitor Pamplona d9396ce453 Migrating to the default Pager from Accompanist's library. 2023-04-20 10:55:00 -04:00
Vitor Pamplona 817c5172b6 Clearing some warnings up 2023-04-20 10:54:35 -04:00
Vitor Pamplona cfdb20892e Updating dependencies 2023-04-20 10:53:09 -04:00
Vitor Pamplona 75922f93bc Moving ReactionsRow into a Lazy Grid 2023-04-20 10:09:28 -04:00
Vitor Pamplona 42801e3eca Using the URL Detector (which is faster) to catch urls in the text 2023-04-20 10:08:32 -04:00
Vitor Pamplona 64321dbe7c No need to sort. It will be resorted later. 2023-04-20 10:07:34 -04:00
Vitor Pamplona b11b52b60c typo 2023-04-20 10:07:10 -04:00
Vitor Pamplona 00e5470642 Passing the size of the robohash rendering to the request in the hopes to improve performance. 2023-04-20 10:07:03 -04:00
Vitor Pamplona 7ee3b8a196 Improves performance of video loading and playback. 2023-04-20 10:06:12 -04:00
Vitor PamplonaandGitHub 585bb57f64 Merge pull request #356 from vitorpamplona/additive_feed_test
Additive feeds (No more list reloadings in the main screens)
2023-04-19 16:49:37 -04:00
Vitor Pamplona 6981fe8f8a Adapting interfaces for the additive filter. 2023-04-19 16:35:26 -04:00
Vitor Pamplona abdad7fbea solve recursive method call 2023-04-19 15:57:27 -04:00
Vitor Pamplona 57b35398be Activates updates 2023-04-19 15:57:17 -04:00
Vitor Pamplona 47398e6e86 Starts with an empty set as database 2023-04-19 15:56:58 -04:00
Vitor Pamplona e775ae9ada Adding the new parameter. 2023-04-19 13:54:00 -04:00
Vitor Pamplona 6a9a321e2b Fixing format 2023-04-19 13:00:52 -04:00
Vitor Pamplona 0aee4407db Merge remote-tracking branch 'origin/HEAD' into additive_feed_test
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
2023-04-19 13:00:45 -04:00
Vitor Pamplona a816fa561a Declares the use of the camera as a feature. 2023-04-19 11:34:38 -04:00
Vitor Pamplona dc745c3cdb disables errors due to missing translations 2023-04-19 11:33:03 -04:00
Vitor Pamplona 0b30c83143 Moves coffee.xml to the right directory. 2023-04-19 11:32:52 -04:00
Vitor Pamplona dc8804933d moving source code compatibility to java 17 2023-04-19 11:27:20 -04:00
Vitor Pamplona b28f0bff74 Updating build scripts to java 17 2023-04-19 11:24:49 -04:00
Vitor PamplonaandGitHub e96e3c0760 Merge pull request #353 from ryogrid/quote_problem
fix bug: quote repost is tied up to a post which is replyed a little while ago
2023-04-19 10:38:40 -04:00
Vitor PamplonaandGitHub b7ea167420 Merge branch 'main' into quote_problem 2023-04-19 10:38:20 -04:00
Vitor Pamplona 744a55f497 Caching channel info JSON parse in NoteCompose. 2023-04-19 10:34:10 -04:00
Vitor Pamplona b55f999efb Slight performance improvements on tag search. 2023-04-19 10:33:52 -04:00
Vitor Pamplona 4e09d9e54a Performance Improvements for Zaps in Polls. 2023-04-19 10:33:02 -04:00
Vitor Pamplona 5afa333fab Migration to Android Studio 2022.2.1 and Gradle 8 2023-04-19 09:58:11 -04:00
Ryo Kanbayashi c0946f5c0e fix bug: quote repost is tied up to a post which is replyed a little while ago. (2) 2023-04-19 22:50:32 +09:00
Vitor PamplonaandGitHub 3ece4fcf8b Merge pull request #352 from naderbal/drawer_back
Intercept back press when drawer is opened to close drawer
2023-04-18 17:10:15 -04:00
Vitor PamplonaandGitHub b1193c5d11 Merge pull request #344 from ZsZolee/main
Added new Hungarian translations
2023-04-18 17:09:43 -04:00
Vitor Pamplona 12b0afd9c6 Display note IDHex when measuring time. 2023-04-18 08:46:12 -04:00
Vitor Pamplona df45d68b74 Caches user profile in Account. 2023-04-18 08:45:50 -04:00
Vitor Pamplona 11bf702e02 Moves DropDown state to the IO thread. 2023-04-18 08:45:34 -04:00
Vitor Pamplona 47e2683aa3 Moves firstIsTaggedHash search to the IO thread. 2023-04-18 08:45:02 -04:00
Vitor Pamplona e4fc00669e Compares users by reference. 2023-04-18 08:44:32 -04:00
Vitor Pamplona f64db1271b Uses createdAt instead of the card object, which can change randomly, to determine when to call LaunchedEffects. 2023-04-18 08:44:11 -04:00
Vitor Pamplona 35647a8361 Fixes DM link in Message Notifications 2023-04-18 08:42:58 -04:00
Vitor Pamplona a2c8658972 Only calculates nip05 after identifying if it is correct. 2023-04-18 08:42:32 -04:00
Vitor Pamplona 38e18cf0a7 Simplifies comparison for Channels via Hex code instead of objects 2023-04-18 08:41:59 -04:00
Vitor Pamplona 78c9974036 Removes invalidateFilters Logging 2023-04-18 08:41:29 -04:00
Ryo Kanbayashi 180083147d removed needless added newline. 2023-04-16 22:26:55 +09:00
Ryo Kanbayashi 50b0b5af37 fix bug: quote repost is tied up to a post which is replyed a little while ago. 2023-04-16 22:12:54 +09:00
nalbaltaji 9d27b8d47e Intercept back press when drawer is opened to close drawer 2023-04-15 18:59:44 +02:00
ZoltanandGitHub 401d807d29 Added new Hungarian translations 2023-04-11 10:26:19 +02:00
Vitor PamplonaandGitHub eab202f721 Merge pull request #340 from toadlyBroodle/main
#zappoll hashtag
2023-04-10 10:07:13 -04:00
Vitor PamplonaandGitHub f726c656b5 Merge pull request #343 from believethehype/zaptypes
introduce zap types, implemented non-zaps in custom zap dialog
2023-04-10 10:03:35 -04:00
Believethehype 37dab1c8b6 Update LnZapEvent.kt 2023-04-10 13:54:29 +02:00
Believethehype 9a5d8522fb added anonymous zaps 2023-04-10 13:25:09 +02:00
Believethehype 5abbd07722 introduce zap types, implemented non-zaps in custom zap dialog
Non-Zaps allow to tip the author of a note without using the zap mechanism (no record on the note)
2023-04-09 23:43:36 +02:00
toadlyBroodle 13579f9473 add #zappoll to new poll messages 2023-04-09 11:26:02 +09:00
Vitor Pamplona cea9cdcd34 v0.32.3 2023-04-07 18:33:36 -04:00
Vitor Pamplona ed22155940 Simplifying tag structure 2023-04-07 18:30:34 -04:00
Vitor Pamplona 43497f2ce0 fixes format 2023-04-07 18:26:19 -04:00
Vitor Pamplona 80e58f72f8 Fixes nprofile1 tags 2023-04-07 18:26:13 -04:00
Vitor Pamplona 234751a4b3 Avoids loading markdown style when it won't be used. 2023-04-07 18:11:50 -04:00
Vitor Pamplona ef567110d0 Moving processing flags to Launched Effect. 2023-04-07 18:02:54 -04:00
Vitor Pamplona 42b89f438b Only calculates follows when needed. 2023-04-07 17:44:40 -04:00
Vitor Pamplona b01df2b38e Fixes MultiSet Compose Routes 2023-04-07 17:41:00 -04:00
Vitor Pamplona b89efb547e Correctly points Users to the right DM when clicking in the notification. 2023-04-07 17:38:25 -04:00
Vitor Pamplona e6554a5b4a Moves tags calculation to Launched Effect 2023-04-07 17:38:02 -04:00
Vitor Pamplona c58b13e2f7 Simplifies modifiers 2023-04-07 17:37:25 -04:00
Vitor Pamplona 241372ce61 Adds DMs to the id search filter 2023-04-07 17:37:07 -04:00
Vitor Pamplona 49ed594f53 Caches logged user in Note compose 2023-04-07 17:21:21 -04:00
Vitor Pamplona 03b76357ff Migrates poll and invoice option availability to a mutable state 2023-04-07 17:20:18 -04:00
Vitor Pamplona 97490bb6ef Using bitcoin icon instead of dollar. 2023-04-07 17:19:47 -04:00
Vitor Pamplona dbf5267c5c Moves LnInvoice, Tags, BechLinks and Previews to LaunchedEffect 2023-04-07 16:58:25 -04:00
Vitor Pamplona e1ce638d7f Compares by reference to simplify code 2023-04-07 16:56:20 -04:00
Vitor Pamplona 88b80a0da1 Typo 2023-04-07 16:56:08 -04:00
Vitor Pamplona d6b571590a Sends simpler object to help compose's stability 2023-04-07 16:55:46 -04:00
Vitor Pamplona 83f3af340f Uses Kotlin's rounding function instead of java 2023-04-07 16:55:15 -04:00
Vitor PamplonaandGitHub e9cf9b21a8 Merge pull request #339 from toadlyBroodle/main
Prevent author vote manipulation
2023-04-07 14:43:00 -04:00
Vitor PamplonaandGitHub b1645590fe Merge pull request #336 from wklkejw239/patch-1
add contributors in README.md
2023-04-07 14:41:48 -04:00
Vitor Pamplona a5da4ea53d Streamlines Global filter for performance 2023-04-07 14:26:23 -04:00
Vitor Pamplona 3a082d131f Streamlines Notification filter for performance 2023-04-07 14:26:07 -04:00
Vitor Pamplona 28d501ed02 Uses more direct functions to get and compare tags as opposed to the fail safe options. 2023-04-07 14:25:38 -04:00
Vitor Pamplona a04fd4a9f2 Matches parameter names to the Event class. 2023-04-07 14:25:03 -04:00
Vitor Pamplona 2424dc3961 Brings the findCitedUsers function to BaseTextNote Event with cache. 2023-04-07 14:24:43 -04:00
Vitor Pamplona 58f1515387 Displays feed performance measurement before clipping to 1000 objects 2023-04-07 14:24:00 -04:00
Vitor Pamplona 9ee3da3910 Uses the inHidden in Hex 2023-04-07 14:23:34 -04:00
Vitor Pamplona cc44b4dd55 Removes reposts from conversations 2023-04-07 14:23:19 -04:00
Vitor Pamplona b8e4ccfc28 Lint mentions cannot be null. 2023-04-07 14:22:48 -04:00
Vitor Pamplona 0fc6757d4a Adds inHidden check for hex keys 2023-04-07 14:22:16 -04:00
toadlyBroodle ee94cc6bf6 don't allow poll authors to vote in their own polls 2023-04-07 08:41:34 +09:00
Vitor Pamplona f224fe6b40 v0.32.2 2023-04-06 12:23:35 -04:00
Vitor Pamplona 403a1723de Previews Notes that are not a citation using #[n] 2023-04-06 11:59:21 -04:00
Vitor Pamplona 6cf1960df4 Adds a New Message Tagger to Chats (tags Notes and Users by default) and Private Message (doesn't tag Users and Notes) 2023-04-06 11:58:49 -04:00
Vitor Pamplona 8676752f19 Fixes Channel's reply to filtration (removes citations and the channel id from replies) 2023-04-06 11:57:28 -04:00
Vitor Pamplona 7012d949c2 Fixes formatting 2023-04-06 11:56:23 -04:00
Vitor Pamplona 8344274011 Adds Invoice Creation to New Posts. 2023-04-06 11:54:28 -04:00
vincivermeerandGitHub 2bda50ad4a add contributors in README.md
Hello everyone!

I added the contributors in the readme.md file in the repository: vitorpamplona/amethyst. I would like to contribute to this pull-request and I hope you all accept it.

what do you all think of this idea?
2023-04-06 00:11:49 -03:00
Vitor Pamplona b0953310c2 Making the poll click animation wider. 2023-04-05 11:10:14 -04:00
Vitor Pamplona 65910295db Adds notification for Zaps that don't have a Note attached to them. 2023-04-05 08:46:25 -04:00
Vitor Pamplona 7a957bd6ec Initializing indexes with large sizes from the start. 2023-04-05 08:45:58 -04:00
Vitor Pamplona 292ce779a1 Lazy initialization of the json mapper (300ms) 2023-04-05 08:45:33 -04:00
Vitor Pamplona 924b21cdfc Reducing the use of Slow disk access of Local Preferences. 2023-04-05 08:44:48 -04:00
Vitor PamplonaandGitHub 7b539e63bd Merge pull request #334 from Poussinou/patch-1
Update README.md
2023-04-05 08:21:06 -04:00
PoussinouandGitHub d37fef94fe Update README.md 2023-04-05 21:24:47 +09:30
Vitor PamplonaandGitHub 93a1853317 Merge pull request #332 from braydonf/readme
Update readme gradlew install commands
2023-04-04 19:48:16 -04:00
Braydon 9944f98bde Update readme gradlew install commands 2023-04-04 16:24:44 -07:00
Vitor Pamplona 05138232f4 Fixes missing function 2023-04-04 16:12:20 -04:00
Vitor Pamplona 83d7085375 Hides Poll option when replying to channel or DMs. 2023-04-04 16:10:55 -04:00
Vitor Pamplona a8a3915c8d Allowing replies of PMs from Notifications. 2023-04-04 16:09:54 -04:00
Vitor Pamplona 83bcf53583 v0.32.1 2023-04-04 14:11:05 -04:00
Vitor Pamplona 875d9544ec Fixes Poll Color for Light mode. 2023-04-04 13:59:02 -04:00
Vitor Pamplona 7ce2a0bbea Makes sure before and after voting the padding is the same. 2023-04-04 13:57:06 -04:00
Vitor Pamplona a3b9ea6a42 Fixes format 2023-04-04 13:51:27 -04:00
Vitor Pamplona ca2c811859 Fixes Voted option highlight. 2023-04-04 13:49:41 -04:00
Vitor Pamplona b09bb0c3c5 Fixes margins when images are used in the poll 2023-04-04 13:39:12 -04:00
Vitor Pamplona a68ea1abe5 Adds margin between the reply note and the text 2023-04-04 12:35:42 -04:00
Vitor Pamplona 02a9aaab60 Removes the 5dp space on the generic RichText Viewer 2023-04-04 12:29:25 -04:00
Vitor Pamplona efd2ddba71 Adds payment animation to Polls 2023-04-04 12:25:32 -04:00
Vitor Pamplona 9a6f88b81b Turning LocalCache Listeners into an Additive Feed type. 2023-03-29 15:14:52 -04:00
102 changed files with 3152 additions and 1449 deletions
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
+24 -2
View File
@@ -4,6 +4,15 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
@@ -76,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
## Setup
Make sure to have the following pre-requisites installed:
1. Java 11
1. Java 17
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -106,8 +115,15 @@ Build the app:
```
## Installing on device
For the F-Droid build:
```bash
./gradlew installDebug
./gradlew installFdroidDebug
```
For the Play build:
```bash
./gradlew installPlayDebug
```
## How to Deploy
@@ -151,6 +167,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
## Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
</a>
# MIT License
Copyright (c) 2023 Vitor Pamplona
+13 -10
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 115
versionName "0.32.0"
versionCode 122
versionName "0.34.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -57,12 +57,12 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '11'
jvmTarget = '17'
}
buildFeatures {
@@ -78,10 +78,14 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -142,15 +146,14 @@ dependencies {
// view svgs
implementation "io.coil-kt:coil-svg:$coil_version"
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
+2
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
@@ -27,10 +27,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
val hasPrivKey: Boolean = false
)
private object PrefKeys {
@@ -38,8 +35,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
@@ -59,53 +54,73 @@ private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
private var _currentAccount: String? = null
private fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
}
private fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
encryptedPreferences().edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private var _savedAccounts: List<String>? = null
private fun savedAccounts(): List<String> {
if (_savedAccounts == null) {
_savedAccounts = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
}
return _savedAccounts!!
}
private fun updateSavedAccounts(accounts: List<String>) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(accounts)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
updateCurrentAccount(npub)
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
updateCurrentAccount(npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
updateSavedAccounts(accounts)
}
}
@@ -145,11 +160,11 @@ object LocalPreferences {
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
if (savedAccounts().isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
} else if (currentAccount() == npub) {
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
@@ -159,17 +174,8 @@ object LocalPreferences {
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
}
}
@@ -189,13 +195,11 @@ object LocalPreferences {
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
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()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount).apply {
encryptedPreferences(currentAccount()).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -247,7 +251,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
@@ -261,23 +265,25 @@ object LocalPreferences {
hideBlockAlertDialog,
latestContactList
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount).edit().apply {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount).run {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
@@ -314,6 +320,6 @@ object LocalPreferences {
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
updateCurrentAccount(npub)
}
}
@@ -18,7 +18,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory {
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -35,7 +35,9 @@ object VideoCache {
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}
fun get(): CacheDataSource.Factory {
return cacheDataSourceFactory
}
}
@@ -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.FileHeader
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -54,8 +55,14 @@ class Account(
val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
return userProfileCache ?: run {
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
userProfileCache = myUser
myUser
}
}
fun followingChannels(): List<Channel> {
@@ -137,7 +144,7 @@ class Account(
}
}
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? {
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
if (!isWriteable()) return null
note.event?.let { event ->
@@ -147,7 +154,8 @@ class Account(
?: localRelays.map { it.url }.toSet(),
loggedIn.privKey!!,
pollOption,
message
message,
zapType
)
}
return null
@@ -171,14 +179,15 @@ class Account(
return createZapRequestFor(user)
}
fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(
userPubKeyHex,
userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(),
loggedIn.privKey!!,
message
message,
zapType
)
}
@@ -349,6 +358,25 @@ class Account(
}
}
fun sendHeader(headerInfo: FileHeader): Note? {
if (!isWriteable()) return null
val signedEvent = FileHeaderEvent.create(
url = headerInfo.url,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
if (!isWriteable()) return
@@ -402,10 +430,11 @@ class Account(
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = ChannelMessageEvent.create(
@@ -419,12 +448,12 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = emptyList<String>()
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
@@ -715,14 +744,15 @@ class Account(
}
}
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun isHidden(user: User) = isHidden(user.pubkeyHex)
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet() ?: emptySet()
return userProfile().cachedFollowingKeySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
return userProfile().cachedFollowingTagSet()
}
fun isAcceptable(user: User): Boolean {
@@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
@@ -17,16 +17,18 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -187,7 +189,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
@@ -216,7 +218,7 @@ object LocalCache {
author.addNote(note)
refreshObservers()
refreshObservers(note)
}
}
@@ -253,7 +255,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
@@ -266,7 +268,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
refreshObservers(note)
}
}
@@ -284,8 +286,6 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
refreshObservers()
}
}
@@ -307,7 +307,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -351,7 +351,7 @@ object LocalCache {
recipient.addMessage(author, note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: DeletionEvent) {
@@ -399,7 +399,7 @@ object LocalCache {
}
if (deletedAtLeastOne) {
live.invalidateData()
// refreshObservers()
}
}
@@ -425,7 +425,7 @@ object LocalCache {
it.addBoost(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
@@ -463,6 +463,8 @@ object LocalCache {
it.addReport(note)
}
}
refreshObservers(note)
}
fun consume(event: ReportEvent, relay: Relay?) {
@@ -493,6 +495,8 @@ object LocalCache {
repliesTo.forEach {
it.addReport(note)
}
refreshObservers(note)
}
fun consume(event: ChannelCreateEvent) {
@@ -509,7 +513,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
}
@@ -529,7 +533,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -563,9 +567,9 @@ object LocalCache {
return
}
val replyTo = event.replyTos()
val replyTo = event.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, replyTo)
@@ -576,7 +580,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -593,15 +597,14 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, repliesTo)
@@ -619,6 +622,8 @@ object LocalCache {
mentions.forEach {
it.addZap(zapRequest, note)
}
refreshObservers(note)
}
fun consume(event: LnZapRequestEvent) {
@@ -642,6 +647,21 @@ object LocalCache {
mentions.forEach {
it.addZap(note, null)
}
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun findUsersStartingWith(username: String): List<User> {
@@ -693,11 +713,6 @@ object LocalCache {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
@@ -748,30 +763,23 @@ object LocalCache {
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val live: LocalCacheLiveData = LocalCacheLiveData()
private fun refreshObservers() {
live.invalidateData()
private fun refreshObservers(newNote: Note) {
live.invalidateData(newNote)
}
}
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) {
postValue(bundledNewNotes)
}
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}
class LocalCacheState(val cache: LocalCache)
@@ -53,13 +53,14 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? {
val channelHex =
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.let { it.id }
fun channelHex(): HexKey? {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
}
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address(): ATag? = null
@@ -194,15 +195,15 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author == user }
return zaps.any { it.key.author === user }
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
return reactions.any { it.author === user }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author == user }
return boosts.any { it.author === user }
}
fun reportsBy(user: User): Set<Note> {
@@ -270,45 +271,6 @@ open class Note(val idHex: String) {
)
}
fun directlyCiteUsersHex(): Set<HexKey> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile ||
(userProfile in directlyCiteUsers()) ||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
@@ -295,7 +295,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this == it.author }
return messagesToUser.roomMessages.any { this === it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -387,6 +387,10 @@ class UserMetadata {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -0,0 +1,203 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Returns width/height
*/
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
return numCompX.toFloat() / numCompY.toFloat()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}
@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
class BlurHashFetcher(
private val options: Options,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): FetchResult {
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap = BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt()
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return BlurHashFetcher(options, data)
}
}
}
object BlurHashRequester {
fun imageRequest(context: Context, message: String): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}
@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val blurHash: String?,
val description: String? = null
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(imageData).toHexKey()
val size = imageData.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height
)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
}
} else {
null
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, ""))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
}
}
}
}
@@ -75,8 +75,9 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
@@ -154,7 +155,7 @@ abstract class NostrDataSource(val debugName: String) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
@@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
tags = mapOf(
"t" to listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
@@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
@@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
@@ -116,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, FileHeaderEvent.kind
),
ids = interestedEvents.toList()
)
@@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
)
}
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -13,7 +13,27 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
private var citedUsersCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
val matcher = tagSearch.matcher(content)
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag.size > 1 && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
citedUsersCache = returningList
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
@@ -22,10 +42,10 @@ open class BaseTextNoteEvent(
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
if (tag != null && tag.size > 1 && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
if (tag != null && tag.size > 1 && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
@@ -12,11 +12,10 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
@@ -38,23 +38,23 @@ 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 taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
ATag.parse(aTagValue, relay)
}
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[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 isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun getPoWRank(): Int {
var rank = 0
@@ -79,7 +79,7 @@ open class Event(
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
} catch (e: Exception) {
null
}
@@ -222,6 +222,7 @@ open class Event(
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(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)
@@ -24,11 +24,11 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
@@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1063
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
url: String,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
var tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
data class AESGCM(val key: String, val nonce: String)
@@ -4,7 +4,6 @@ import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
@@ -14,23 +13,37 @@ class LnZapEvent(
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedPollOption(): Int? = containedPost()?.tags
?.filter { it.firstOrNull() == POLL_OPTION }
?.getOrNull(0)?.getOrNull(1)?.toInt()
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun amount(): BigDecimal? {
return amount
private fun containedPost(): LnZapRequestEvent? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
} as? LnZapRequestEvent
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
init {
zapRequest = containedPost()
}
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun zappedPollOption(): Int? = try {
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
} catch (e: Exception) {
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
null
}
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
override fun amount() = amount
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
@@ -40,31 +53,23 @@ class LnZapEvent(
null
}
}
override fun message(): String {
return message
}
val message = content
override fun containedPost(): Event? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
return content
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 9735
}
enum class ZapType() {
PUBLIC,
PRIVATE, // not yet implemented
ANONYMOUS,
NONZAP
}
}
@@ -10,11 +10,11 @@ interface LnZapEventInterface : EventInterface {
fun zappedAuthor(): List<String>
fun zappedRequestAuthor(): String?
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?
fun containedPost(): Event?
fun message(): String
}
@@ -13,8 +13,10 @@ class LnZapRequestEvent(
content: String,
sig: HexKey
) : 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 zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object {
const val kind = 9734
@@ -25,10 +27,12 @@ class LnZapRequestEvent(
privateKey: ByteArray,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
@@ -40,9 +44,13 @@ class LnZapRequestEvent(
if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
}
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
tags = tags + listOf(listOf("anon", ""))
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
val sig = Utils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
@@ -51,17 +59,24 @@ class LnZapRequestEvent(
relays: Set<String>,
privateKey: ByteArray,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
tags = tags + listOf(listOf("anon", ""))
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
val sig = Utils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
@@ -20,24 +20,17 @@ class PollNoteEvent(
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun pollOptions(): Map<Int, String> {
val map = mutableMapOf<Int, String>()
tags.filter { it.first() == POLL_OPTION }
.forEach { map[it[1].toInt()] = it[2] }
return map
}
fun pollOptions() =
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
.associate { it[1].toInt() to it[2] }
fun getTagInt(property: String): Int? {
val tagList = tags.filter {
it.firstOrNull() == property
}
val tag = tagList.getOrNull(0)
val s = tag?.getOrNull(1)
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
return if (s.isNullOrBlank() || s == "null") {
return if (number.isNullOrBlank() || number == "null") {
null
} else {
s.toInt()
number.toInt()
}
}
@@ -21,7 +21,7 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -29,7 +29,7 @@ class PrivateDmEvent(
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
@@ -14,8 +14,8 @@ class ReactionEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object {
const val kind = 7
@@ -30,7 +30,7 @@ class ReportEvent(
}
fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "e" }
.map {
ReportedKey(
it[1],
@@ -39,7 +39,7 @@ class ReportEvent(
}
fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "p" }
.map {
ReportedKey(
it[1],
@@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() {
null
}
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
@@ -15,7 +15,7 @@ object ImageUploader {
fun uploadImage(
uri: Uri,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
@@ -64,7 +64,7 @@ object ImageUploader {
"There must be an uploaded image URL in the response"
}
onSuccess(url)
onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
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) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
message = message.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} 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)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
}
}
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel)
pollViewModel.pollOptions.values.forEachIndexed { index, element ->
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(pollViewModel, index)
}
Button(
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { index, item ->
) { _, item ->
UserLine(item, account) {
pollViewModel.autocompleteWithUser(item)
}
@@ -12,6 +12,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -29,6 +31,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
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.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -42,6 +45,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@@ -163,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
)
if (postViewModel.wantsPoll) {
postViewModel.pollOptions.values.forEachIndexed { index, element ->
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
@@ -182,6 +186,28 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = {
postViewModel.wantsInvoice = false
}
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
@@ -244,8 +270,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.upload(it, context)
}
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
if (postViewModel.canUsePoll) {
val hashtag = stringResource(R.string.poll_hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
}
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
}
@@ -269,14 +305,42 @@ private fun AddPollButton(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(20.dp),
tint = Color.White
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(20.dp),
tint = Color.White
tint = MaterialTheme.colors.onBackground
)
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
null,
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
}
}
@@ -13,8 +13,9 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
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
@@ -38,6 +39,7 @@ open class NewPostViewModel : ViewModel() {
var userSuggestionAnchor: TextRange? = null
// Polls
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
@@ -52,6 +54,10 @@ open class NewPostViewModel : ViewModel() {
var isValidConsensusThreshold = mutableStateOf(true)
var isValidClosedAt = mutableStateOf(true)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -67,7 +73,7 @@ open class NewPostViewModel : ViewModel() {
this.mentions = currentMentions.plus(replyUser)
}
}
} ?: {
} ?: run {
replyTos = null
mentions = null
}
@@ -76,84 +82,24 @@ open class NewPostViewModel : ViewModel() {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
this.account = account
}
open fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
open fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
open fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
open fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
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) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} 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)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
tagger.run()
if (wantsPoll) {
account?.sendPoll(newMessage, replyTos, mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions)
}
cancel()
@@ -165,13 +111,39 @@ open class NewPostViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
onSuccess = { imageUrl, mimeType ->
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
onReady = {
val note = account?.sendHeader(it)
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
},
onError = {
@@ -196,6 +168,8 @@ open class NewPostViewModel : ViewModel() {
valueMinimum = null
consensusThreshold = null
closedAt = null
wantsInvoice = false
}
open fun findUrlInMessage(): String? {
@@ -245,7 +219,20 @@ open class NewPostViewModel : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage &&
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
if (include) {
updateMessage(TextFieldValue(message.text + " $hashtag"))
} else {
updateMessage(
TextFieldValue(
message.text.replace(" $hashtag", "")
.replace(hashtag, "")
)
)
}
}
}
@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* This class is designed to have a waiting time between two calls of invalidate
@@ -44,3 +45,37 @@ class BundledUpdate(
}
}
}
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private var onlyOneInBlock = AtomicBoolean()
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
atomicSet.updateAndGet() {
it + newObject
}
if (onlyOneInBlock.getAndSet(true)) {
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate(atomicSet.getAndSet(emptySet()))
delay(delay)
onUpdate(atomicSet.getAndSet(emptySet()))
} finally {
withContext(NonCancellable) {
onlyOneInBlock.set(false)
}
}
}
}
}
@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
baesNote: Note,
baseNote: Note,
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -5,10 +5,34 @@ 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.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MayBeWithdrawal(lnurlWord: String) {
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lnurlWord) {
withContext(Dispatchers.IO) {
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
}
}
lnWithdrawal?.let {
ClickableWithdrawal(withdrawalString = it)
}
?: Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
val amount = try {
LnInvoiceUtil.getAmountInSats(lnInvoice)
} catch (e: Exception) {
e.printStackTrace()
null
fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
} catch (e: Exception) {
e.printStackTrace()
null
}
lnInvoice = Pair(myInvoice, myInvoiceAmount)
}
}
}
lnInvoice?.let {
InvoicePreview(it.first, it.second)
}
?: Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
val context = LocalContext.current
Column(
@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -36,14 +34,22 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) {
fun InvoiceRequest(
lud16: String,
toUserPubKeyHex: String,
account: Account,
titleText: String? = null,
buttonText: String? = null,
onSuccess: (String) -> Unit,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -73,7 +79,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
)
Text(
text = stringResource(R.string.lightning_tips),
text = titleText ?: stringResource(R.string.lightning_tips),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -130,20 +136,14 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, LnZapEvent.ZapType.PUBLIC)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
},
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
@@ -159,7 +159,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
Text(text = buttonText ?: stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
}
}
}
@@ -2,7 +2,6 @@ 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.*
@@ -14,7 +13,8 @@ import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -33,13 +33,18 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
@@ -80,72 +85,71 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
Column(modifier = modifier.animateContentSize()) {
Column(modifier = modifier) {
if (content.startsWith("# ") ||
content.contains("##") ||
content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = content,
content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
} 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 (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
}
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableVideo(fullUrl)
} else {
null
}
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
@@ -154,41 +158,27 @@ fun RichTextViewer(
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else {
UrlPreview(word, "$word ")
}
val img = imagesForPager[word]
if (img != null) {
ZoomableContentView(img, imageList)
} else if (urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
@@ -200,7 +190,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -213,8 +203,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
@@ -222,7 +219,7 @@ fun RichTextViewer(
)
}
} else {
if (isValidURL(word)) {
if (urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
@@ -239,7 +236,13 @@ fun RichTextViewer(
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
@@ -251,7 +254,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -264,8 +267,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
@@ -280,50 +290,147 @@ fun RichTextViewer(
}
}
@Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
if (parsedNip19.type == Nip19.Type.USER) {
val userHex = parsedNip19.hex
val userBase = LocalCache.getOrCreateUser(userHex)
val userState by userBase.live().metadata.observeAsState()
val displayName = userState?.user?.bestDisplayName()
if (displayName !== null) {
return displayName
}
}
return null
}
@Composable
private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
returnContent += "[$word]($word) "
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
} else {
"$word "
}
} else {
"$word "
}
} else {
returnContent += "$word "
}
}
returnContent += "\n"
}
return returnContent
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun isBechLink(word: String): Boolean {
val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7).lowercase()
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Composable
fun BechLink(word: String, navController: NavController) {
val nip19Route = Nip19.uriToRoute(word)
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
if (nip19Route == null) {
Text(text = "$word ")
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars)
}
} else {
nip19Route = it
}
}
}
}
if (canPreview) {
baseNotePair?.let {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
Text(
"${it.second} "
)
} ?: nip19Route?.let {
ClickableRoute(it, navController)
} ?: Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
nip19Route?.let {
ClickableRoute(it, navController)
} ?: Text(text = "$word ")
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
fun HashTag(word: String, navController: NavController) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
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)
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = 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 (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
}
}
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
tagSuffixPair?.let { tagPair ->
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
append("#${tagPair.first}")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
@@ -346,14 +453,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
)
)
@@ -364,69 +469,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
inlineContent = inlineContent
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
tagPair.second?.ifBlank { "" }?.let {
Text(text = "$it ")
}
} ?: 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)
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val (index, extraCharacters) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (index == null) {
return Text(text = "$word ")
}
if (index >= 0 && index < tags.size) {
if (tags[index][0] == "p") {
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
if (baseUser != null) {
ClickableUserTag(baseUser, navController)
Text(text = "$extraCharacters ")
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
} else if (tags[index][0] == "e") {
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableNoteTag(note, navController)
Text(text = "$extraCharacters ")
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
}
}
}
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
} else {
Text(text = "$word ")
}
}
baseUserPair?.let {
ClickableUserTag(it.first, navController)
Text(text = "${it.second} ")
}
baseNotePair?.let {
if (canPreview) {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
}
} else {
ClickableNoteTag(it.first, navController)
Text(text = "${it.second} ")
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ")
}
}
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import okio.Buffer
import java.security.MessageDigest
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
class HashImageFetcher(
private val context: Context,
private val size: Size,
private val data: Uri
) : Fetcher {
@@ -79,16 +81,17 @@ class HashImageFetcher(
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
return HashImageFetcher(options.context, options.size, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest {
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.crossfade(100)
.build()
}
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
@Composable
fun RobohashAsyncImage(
robot: String,
robotSize: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
AsyncImage(
model = Robohash.imageRequest(LocalContext.current, robot),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
with(LocalDensity.current) {
AsyncImage(
model = Robohash.imageRequest(
LocalContext.current,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
var imageErrors = setOf<String>()
@Composable
fun RobohashFallbackAsyncImage(
robot: String,
model: String?,
robotSize: Dp,
model: String,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val context = LocalContext.current
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
if (imageErrors.contains(model)) {
RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val context = LocalContext.current
val painter = with(LocalDensity.current) {
rememberAsyncImagePainter(
model = Robohash.imageRequest(
context,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
)
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
onError = {
imageErrors = imageErrors + model
}
)
}
}
@Composable
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (model.url == null) {
val proxy = model.proxyUrl()
if (proxy == null) {
RobohashAsyncImage(
robot = robot,
robotSize = model.size,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
} else {
RobohashFallbackAsyncImage(
robot = robot,
model = model.proxyUrl(),
robotSize = model.size,
model = proxy,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ 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.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -22,11 +25,8 @@ 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)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
@@ -39,7 +39,7 @@ fun SlidingCarousel(
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(count = itemsCount, state = pagerState) { page ->
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
itemContent(page)
}
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
)
) {
Column {
val url = URL(previewInfo.url)
val validatedUrl = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(url, previewInfo.image).toString()
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
AsyncImage(
model = imageUrl,
contentDescription = stringResource(R.string.preview_card_image_for, url),
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
text = validatedUrl.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
@@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache
@Composable
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val exoPlayer = remember {
val exoPlayer = remember(videoUri) {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri))
)
prepare()
}
}
val playerView = remember {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
innerOnDialog(it)
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
else -> {}
}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
DisposableEffect(exoPlayer) {
onDispose {
exoPlayer.release()
lifecycle.removeObserver(observer)
}
}
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
playerView
}
)
}
@@ -0,0 +1,408 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.security.MessageDigest
abstract class ZoomableContent(
val url: String,
val description: String? = null,
val hash: String? = null
)
class ZoomableImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null
) : ZoomableContent(url, description, hash)
class ZoomableVideo(
url: String,
description: String? = null,
hash: String? = null
) : ZoomableContent(url, description, hash)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableImage(fullUrl)
} else if (isVideo) {
ZoomableVideo(fullUrl)
} else {
ZoomableImage(fullUrl)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
val mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
)
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
VideoView(content.url) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
}
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
ClickableUrl(urlText = "$content ", url = content.url)
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
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
val pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url)
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url)
}
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
val imageFile = snapshot.data.toFile()
val bytes = imageFile.readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(bytes).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}")
return hash == content.hash
}
return null
}
@Composable
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
if (verifiedHash == null) return
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
Box(
modifier
.width(40.dp)
.height(40.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (verifiedHash == true) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_passed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
modifier = Modifier.size(30.dp)
)
}
} else if (verifiedHash == false) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_failed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Hash",
modifier = Modifier.size(30.dp)
)
}
}
}
}
@@ -1,190 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
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.Alignment
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.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
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.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
ClickableUrl(urlText = "$word ", url = word)
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
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
} else {
VideoView(word) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage])
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(imageUrl: String) {
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(imageUrl)
}
}
}
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : FeedFilter<Note>() {
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
lateinit var channel: Channel
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() }
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : FeedFilter<Note>() {
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null
var withUser: User? = null
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() }
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val myAccount = account
val myUser = withUser
if (myAccount == null || myUser == null) return emptySet()
val messages = myAccount
.userProfile()
.privateChatrooms[myUser] ?: return emptySet()
return collection
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val me = account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
@@ -4,16 +4,37 @@ import android.util.Log
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T>() {
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
val (feed, elapsed) = measureTimedValue {
feed().take(1000)
feed()
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed.take(1000)
}
abstract fun feed(): List<T>
}
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(1000)
} else {
oldList
}
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed
}
abstract fun feed(): List<T>
}
@@ -3,57 +3,48 @@ 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.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
object GlobalFeedFilter : FeedFilter<Note>() {
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val followChannels = account.followingChannels
val followUsers = account.followingKeySet()
val now = System.currentTimeMillis() / 1000
val notes = LocalCache.notes.values
return collection
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
}
.filter {
val channel = it.channelHex()
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
(channel == null || channel !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
it.createdAt()!! <= now
}
.toList()
.toSet()
}
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) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
}
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -8,14 +8,27 @@ 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>() {
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
return LocalCache.notes.values
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myTag = tag ?: return emptySet()
return collection
.asSequence()
.filter {
(
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter<Note>() {
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
.toSet()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -4,26 +4,37 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
return collection
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is RepostEvent) &&
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(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()
}
.sortedBy { it.createdAt() }
.reversed()
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter : FeedFilter<Note>() {
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(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.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
it.isNewThread()
}
.toSet()
}
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(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()
}
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}
@@ -3,52 +3,56 @@ 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
import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : FeedFilter<Note>() {
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val loggedInUser = account.userProfile()
return LocalCache.notes.values
.asSequence()
.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
}
.filter { it ->
it.event !is TextNoteEvent ||
(it.event as? TextNoteEvent)?.taggedEvents()?.any {
LocalCache.checkGetOrCreateNote(it)?.author == loggedInUser
} == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter { it ->
it.event !is PollNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is RepostEvent ||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.sortedBy { it.createdAt() }
.toList()
.reversed()
return sort(innerApplyFilter(LocalCache.notes.values))
}
fun isDifferentAccount(account: Account): Boolean {
return this::account.isInitialized && this.account != account
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val loggedInUser = account.userProfile()
val loggedInUserHex = loggedInUser.pubkeyHex
return collection.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.author !== loggedInUser &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser)
}.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
fun tagsAnEventByUser(note: Note, author: User): Boolean {
val event = note.event
if (event is BaseTextNoteEvent) {
return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
}
if (event is ReactionEvent) {
return note.replyTo?.lastOrNull()?.author === author
}
if (event is RepostEvent) {
return note.replyTo?.lastOrNull()?.author === author
}
return true
}
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -24,10 +23,8 @@ import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -45,14 +42,15 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import nostr.postr.bechToBytes
import nostr.postr.toHex
@Composable
fun AccountSwitchBottomSheet(
@@ -82,90 +80,101 @@ fun AccountSwitchBottomSheet(
accounts.forEach { acc ->
val current = accountUser.pubkeyNpub() == acc.npub
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val baseUser = try {
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
} catch (e: Exception) {
null
}
if (baseUser != null) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
Row(
modifier = Modifier
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.padding(16.dp, 16.dp)
.weight(1f),
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
verticalAlignment = Alignment.CenterVertically
) {
Box(
Row(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
.padding(16.dp, 16.dp)
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
RobohashAsyncImageProxy(
robot = acc.npub.bechToBytes("npub").toHex(),
model = ResizeImage(acc.profilePicture, 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.width(55.dp)
.padding(0.dp)
) {
if (acc.hasPrivKey) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)/*
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
) {
if (acc.hasPrivKey) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
}
}*/
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let {
Text(it)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
if (acc.displayName != null && acc.displayName != npubShortHex) {
Text(acc.displayName)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
}
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
}
}
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.graphics.Rect
import android.util.Log
import android.view.ViewTreeObserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -42,6 +43,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
val bottomNavigationItems = listOf(
Route.Home,
@@ -135,66 +138,75 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = route.icon),
contentDescription = null,
modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp),
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
)
println("Notifiable Icon")
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val (value, elapsed) = measureTimedValue {
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = route.icon),
contentDescription = null,
modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp),
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
)
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
println("Notifiable Icon")
val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache)
val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
}
}
}
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache)
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, db)
}
}
}
if (hasNewItems) {
Box(
Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
) {
if (hasNewItems) {
Box(
modifier = Modifier
Modifier
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
.align(Alignment.TopEnd)
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
Box(
modifier = Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
)
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
)
}
}
}
}
}
Log.d("Notification time", "$elapsed")
}
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppNavigation(
navController: NavHostController,
@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
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.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
val arguments: List<NamedNavArgument> = emptyList()
) {
val base: String
@@ -30,7 +32,7 @@ sealed class Route(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
),
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Search : Route(
@@ -43,13 +45,13 @@ sealed class Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Message : Route(
route = "Message",
icon = R.drawable.ic_dm,
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object BlockedUsers : Route(
@@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? {
return navBackStackEntry?.destination?.route
}
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
val lastTime = cache.load("HomeFollows")
open class LatestItem {
var newestItemPerAccount: Map<String, Note?> = mapOf()
HomeNewThreadFeedFilter.account = account
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
return (
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
?: 0
) > lastTime
if (newestItem == null) {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.feed().firstOrNull { it.createdAt() != null }
)
} else {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
)
}
return newestItemPerAccount[account.userProfile().pubkeyHex]
}
}
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
val lastTime = cache.load("Notification")
object HomeLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
NotificationFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
return (
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
?: 0
) > lastTime
return (newestItem?.createdAt() ?: 0) > lastTime
}
}
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
ChatroomListKnownFeedFilter.account = account
object NotificationLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
return (note.createdAt() ?: 0) > lastTime
return (newestItem?.createdAt() ?: 0) > lastTime
}
}
object MessagesLatestItem {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
return (note.createdAt() ?: 0) > lastTime
}
}
@@ -230,17 +230,15 @@ fun ChatroomMessageCompose(
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.toSet().mapIndexed { _, note ->
if (note.event != null) {
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -248,30 +246,32 @@ fun ChatroomMessageCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
event.channelInfo().name
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
event.channelInfo().about
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
event.channelInfo().picture
channelInfo.picture
?: ""
) + "'"
)
} else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
event.channelInfo().name
channelInfo.name
?: ""
) + "$', {stringResource(R.string.description_to)} '" + (
event.channelInfo().about
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
event.channelInfo().picture
channelInfo.picture
?: ""
) + "'"
)
@@ -286,7 +286,7 @@ fun ChatroomMessageCompose(
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
@@ -296,7 +296,7 @@ fun ChatroomMessageCompose(
TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
@@ -373,6 +373,7 @@ private fun RelayBadges(baseNote: Note) {
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
@@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = messageSetCard) {
LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
},
onLongClick = { popupExpanded = true }
@@ -33,9 +33,11 @@ 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
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -59,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard) {
LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
@@ -78,14 +80,29 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor)
.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
},
onLongClick = { popupExpanded = true }
@@ -227,7 +244,7 @@ fun FastNoteAuthorPicture(
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture(
userHex = user.pubkeyHex,
@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
)
}
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
@@ -5,8 +5,6 @@ import android.graphics.Bitmap
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -61,11 +59,10 @@ import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import kotlin.math.ceil
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@OptIn(ExperimentalTime::class)
@Composable
fun NoteCompose(
baseNote: Note,
@@ -96,7 +93,7 @@ fun NoteCompose(
)
}
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
}
@OptIn(ExperimentalFoundationApi::class)
@@ -116,6 +113,7 @@ fun NoteComposeInner(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val loggedIn = account.userProfile()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
@@ -130,6 +128,19 @@ fun NoteComposeInner(
var moreActionsExpanded by remember { mutableStateOf(false) }
var isAcceptable by remember { mutableStateOf(true) }
var canPreview by remember { mutableStateOf(true) }
LaunchedEffect(key1 = noteReportsState) {
withContext(Dispatchers.IO) {
canPreview = note?.author === loggedIn ||
(note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
isAcceptable = account.isAcceptable(noteForReports)
}
}
val noteEvent = note?.event
val baseChannel = note?.channel()
@@ -141,11 +152,11 @@ fun NoteComposeInner(
),
isBoostedNote
)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
} else if (!isAcceptable && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
loggedIn,
modifier,
isBoostedNote,
navController,
@@ -156,6 +167,8 @@ fun NoteComposeInner(
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
@@ -193,7 +206,20 @@ fun NoteComposeInner(
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
navController.navigate("Room/${note.author?.pubkeyHex}")
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}")
}
@@ -218,7 +244,7 @@ fun NoteComposeInner(
.width(55.dp)
.padding(0.dp)
) {
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
@@ -231,7 +257,7 @@ fun NoteComposeInner(
NoteAuthorPicture(
it,
navController,
account.userProfile(),
loggedIn,
35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
@@ -272,7 +298,7 @@ fun NoteComposeInner(
}
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
baseNote.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
} else {
@@ -286,7 +312,7 @@ fun NoteComposeInner(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isQuotedNote) {
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
NoteAuthorPicture(note, navController, loggedIn, 25.dp)
Spacer(Modifier.padding(horizontal = 5.dp))
NoteUsernameDisplay(note, Modifier.weight(1f))
} else {
@@ -310,7 +336,7 @@ fun NoteComposeInner(
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
@@ -343,11 +369,6 @@ fun NoteComposeInner(
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
@@ -369,17 +390,19 @@ fun NoteComposeInner(
navController = navController
)
} else {
ReplyInformation(note.replyTo, sortedMentions, account, navController)
ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
}
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
Spacer(modifier = Modifier.height(5.dp))
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
.sortedBy { loggedIn.isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
Spacer(modifier = Modifier.height(5.dp))
}
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
@@ -425,7 +448,7 @@ fun NoteComposeInner(
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
LongFormHeader(noteEvent, note, account.userProfile())
LongFormHeader(noteEvent, note, loggedIn)
ReactionsRow(note, accountViewModel)
@@ -443,7 +466,7 @@ fun NoteComposeInner(
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
userAccount = loggedIn,
size = 35.dp
)
}
@@ -469,16 +492,16 @@ fun NoteComposeInner(
thickness = 0.25.dp
)
} else if (noteEvent is PrivateDmEvent &&
noteEvent.recipientPubKey() != account.userProfile().pubkeyHex &&
note.author != account.userProfile()
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
note.author !== loggedIn
) {
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
"@${recepient?.pubkeyNpub()}"
"@${recipient?.pubkeyNpub()}"
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
@@ -499,12 +522,8 @@ fun NoteComposeInner(
} else {
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
if (makeItShort && note.author == account.userProfile()) {
if (makeItShort && note.author == loggedIn) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -522,7 +541,7 @@ fun NoteComposeInner(
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
}
if (noteEvent is PollNoteEvent) {
@@ -559,11 +578,17 @@ fun DisplayFollowingHashtagsInPost(
account: Account,
navController: NavController
) {
var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteEvent) {
withContext(Dispatchers.IO) {
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
}
}
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
val firstTag =
noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
firstTag?.let {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
@@ -580,11 +605,10 @@ fun DisplayFollowingHashtagsInPost(
@Composable
fun DisplayUncitedHashtags(
noteEvent: EventInterface,
hashtags: List<String>,
eventContent: String,
navController: NavController
) {
val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
@@ -754,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) {
}
}
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
if (isImage || isVideo) {
val content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
}
ZoomableContentView(content = content, listOf(content))
} else {
UrlPreview(fullUrl, "$fullUrl ")
}
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
@@ -846,63 +894,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
val noteRelays = noteRelaysState?.note ?: return
var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
withContext(Dispatchers.IO) {
val relayList = noteRelays.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}
val uri = LocalUriHandler.current
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
Spacer(Modifier.height(10.dp))
if (shouldListChange) {
lazyRelayList = relaysToDisplay
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
modifier = Modifier.height(height),
userScrollEnabled = false
) {
items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
Box(Modifier.padding(1.dp).size(15.dp)) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
// .border(1.dp, Color.Red)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
val nextShowMore = relayList.size > 3 && !expanded
if (nextShowMore != showShowMore) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
}
}
}
if (noteRelays.size > 3 && !expanded) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
Spacer(Modifier.height(10.dp))
VerticalRelayPanelWithFlow(lazyRelayList)
if (showShowMore) {
ShowMoreRelaysButton {
expanded = true
}
}
}
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: List<String>
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
RelayIconCompose(url)
}
}
}
@Composable
@Stable
private fun RelayIconCompose(url: String) {
val uri = LocalUriHandler.current
Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = onClick
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
@@ -941,6 +1028,7 @@ fun NoteAuthorPicture(
if (author == null) {
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.width(size)
@@ -1060,6 +1148,13 @@ fun UserPicture(
}
}
data class DropDownParams(
val isFollowingAuthor: Boolean,
val isPrivateBookmarkNote: Boolean,
val isPublicBookmarkNote: Boolean,
val isLoggedUser: Boolean
)
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
@@ -1067,11 +1162,28 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false)
)
}
LaunchedEffect(key1 = note) {
withContext(Dispatchers.IO) {
state = DropDownParams(
accountViewModel.isFollowing(note.author),
accountViewModel.isInPrivateBookmarks(note),
accountViewModel.isInPublicBookmarks(note),
accountViewModel.isLoggedUser(note.author)
)
}
}
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (!accountViewModel.isFollowing(note.author)) {
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -1108,7 +1220,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (accountViewModel.isInPrivateBookmarks(note)) {
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
@@ -1117,7 +1229,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (accountViewModel.isInPublicBookmarks(note)) {
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
@@ -1131,7 +1243,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.broadcast))
}
Divider()
if (accountViewModel.isLoggedUser(note.author)) {
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}
@@ -109,8 +109,6 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
var showDeleteAlertDialog by remember { mutableStateOf(false) }
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
@@ -129,6 +127,9 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (popupExpanded) {
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
Popup(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
@@ -10,7 +9,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,6 +31,7 @@ import androidx.compose.ui.window.Popup
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -49,9 +48,6 @@ fun PollNote(
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
@@ -73,7 +69,7 @@ fun PollNote(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile()) == true) {
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
ZapVote(
baseNote,
accountViewModel,
@@ -91,7 +87,7 @@ fun PollNote(
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = optionTally
progress = optionTally.toFloat()
)
Row(
@@ -102,16 +98,16 @@ fun PollNote(
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) {
Text(
text = "${(optionTally * 100).roundToInt()}%",
text = "${(optionTally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(vertical = 5.dp),
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
@@ -144,7 +140,7 @@ fun PollNote(
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(vertical = 5.dp, horizontal = 10.dp),
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
@@ -188,8 +184,8 @@ fun ZapVote(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
// interactionSource = remember { MutableInteractionSource() },
// indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (!accountViewModel.isWriteable()) {
scope.launch {
@@ -211,6 +207,16 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.poll_author_no_vote),
Toast.LENGTH_SHORT
)
.show()
}
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
scope.launch {
@@ -243,7 +249,8 @@ fun ZapVote(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = LnZapEvent.ZapType.PUBLIC
)
}
} else {
@@ -282,6 +289,7 @@ fun ZapVote(
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -289,12 +297,21 @@ fun ZapVote(
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
}
}
}
@@ -360,7 +377,8 @@ fun FilteredZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -385,7 +403,8 @@ fun FilteredZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -477,7 +496,8 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -502,7 +522,8 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.*
class PollNoteViewModel {
@@ -16,7 +17,9 @@ class PollNoteViewModel {
var valueMaximum: Int? = null
var valueMinimum: Int? = null
private var closedAt: Int? = null
var consensusThreshold: Float? = null
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
fun load(note: Note?) {
pollNote = note
@@ -24,8 +27,10 @@ class PollNoteViewModel {
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -73,47 +78,39 @@ class PollNoteViewModel {
return false
}
fun optionVoteTally(op: Int): Float {
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal())
return if (tally.isNaN()) { // catch div by 0
0f
} else { tally }
}
private fun zappedVoteTotal(): Float {
var total = 0f
pollOptions?.keys?.forEach {
total += zappedPollOptionAmount(it).toFloat()
fun optionVoteTally(op: Int): BigDecimal {
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
return total
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author == user } == true) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.map {
val zappedOption = it.zappedPollOption()
if (zappedOption == option) {
return true
}
if (pollNote?.zaps?.any { it.key.author === user } == true) {
pollNote!!.zaps
.any {
val event = it.value?.event as? LnZapEvent
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
}
}
return false
}
fun zappedPollOptionAmount(option: Int): BigDecimal {
return if (pollNote != null) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
val zappedOption = it.zappedPollOption()
if (zappedOption == option) {
it.amount
} else { null }
}.sumOf { it }
} else {
BigDecimal(0)
}
return pollNote?.zaps?.values?.sumOf {
val event = it?.event as? LnZapEvent
if (event?.zappedPollOption() == option) {
event.amount ?: BigDecimal(0)
} else {
BigDecimal(0)
}
} ?: BigDecimal(0)
}
fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
(it?.event as? LnZapEvent)?.amount ?: BigDecimal(0)
} ?: BigDecimal(0)
}
}
@@ -9,7 +9,6 @@ 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
@@ -55,6 +54,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -63,8 +63,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -86,26 +86,28 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
}
Row(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToReplyTo = baseNote
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ReplyReaction(baseNote, accountViewModel) {
wantsToReplyTo = baseNote
}
}
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToQuote = baseNote
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex)
}
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
ViewCountReaction(baseNote, Modifier.weight(1f))
}
}
@@ -113,19 +115,17 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
fun ReplyReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
showCounter: Boolean = true,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.size(20.dp),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -140,29 +140,32 @@ fun ReplyReaction(
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint
)
ReplyIcon()
}
if (showCounter) {
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
@Composable
private fun ReplyIcon() {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
private fun BoostReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
@@ -228,16 +231,14 @@ private fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier
accountViewModel: AccountViewModel
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
@@ -286,8 +287,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -363,7 +363,8 @@ fun ZapReaction(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = LnZapEvent.ZapType.PUBLIC
)
}
} else if (account.zapAmountChoices.size > 1) {
@@ -455,13 +456,13 @@ fun ZapReaction(
}
@Composable
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
private fun ViewCountReaction(idHex: String) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
modifier = Modifier.size(20.dp),
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
@@ -471,11 +472,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
)
}
Row(modifier = textModifier) {
Row() {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
@@ -560,7 +560,8 @@ fun ZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -585,7 +586,8 @@ fun ZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -606,9 +608,9 @@ fun showCount(count: Int?): String {
if (count == 0) return ""
return when {
count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
count >= 1000000 -> "${Math.round(count / 1000000f)}M"
count >= 1000 -> "${Math.round(count / 1000f)}k"
count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
count >= 1000 -> "${(count / 1000f).roundToInt()}k"
else -> "$count"
}
}
@@ -16,14 +16,13 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.*
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Account, navController: NavController) {
ReplyInformation(replyTo, mentions, account) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@@ -5,12 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -27,15 +22,16 @@ 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.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
var customAmount by mutableStateOf(TextFieldValue("1000"))
var customAmount by mutableStateOf(TextFieldValue("21"))
var customMessage by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
@@ -63,11 +59,21 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
var zappingProgress by remember { mutableStateOf(0f) }
val zapTypes = listOf(
Pair(LnZapEvent.ZapType.PUBLIC, "Public"),
Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"),
Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap")
)
val zapOptions = zapTypes.map { it.second }
var selectedZapType by remember { mutableStateOf(zapTypes[0]) }
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -98,6 +104,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
postViewModel.customMessage.text,
context,
onError = {
zappingProgress = 0f
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
@@ -105,8 +112,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = selectedZapType.first
)
}
onClose()
@@ -174,6 +183,15 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
.weight(1f)
)
}
TextSpinner(
label = "Zap Type",
placeholder = "Public",
options = zapOptions,
onSelect = {
selectedZapType = zapTypes[it]
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
@@ -0,0 +1,109 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
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.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.compositeOver
import androidx.compose.ui.res.stringResource
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.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.clickable {
navController.navigate("User/${zapSetCard.user.pubkeyHex}")
}
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, navController = navController)
}
}
}
}
@@ -35,11 +35,11 @@ class AccountStateViewModel() : ViewModel() {
private fun tryLoginExistingAccount() {
LocalPreferences.loadFromEncryptedStorage()?.let {
login(it)
startUI(it)
}
}
fun login(key: String) {
fun startUI(key: String) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
@@ -56,7 +56,8 @@ class AccountStateViewModel() : ViewModel() {
Account(Persona(Hex.decode(key)))
}
login(account)
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
fun switchUser(npub: String) {
@@ -67,24 +68,22 @@ class AccountStateViewModel() : ViewModel() {
fun newKey() {
val account = Account(Persona())
login(account)
// saves to local preferences
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
@OptIn(DelicateCoroutinesApi::class)
fun login(account: Account) {
LocalPreferences.updatePrefsForLogin(account)
fun startUI(account: Account) {
if (account.loggedIn.privKey != null) {
_accountContent.update { AccountState.LoggedIn(account) }
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
abstract class Card() {
abstract fun createdAt(): Long
@@ -40,6 +41,14 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>) : Card() {
override fun id() = note.idHex + "Z" + createdAt
}
class ZapUserSetCard(val user: User, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = user.pubkeyHex + "U" + createdAt
}
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@@ -128,6 +129,13 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
@@ -5,8 +5,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -14,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -29,7 +31,7 @@ import kotlin.time.measureTimedValue
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -45,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
@Synchronized
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val notes = localFilter.loadTop()
val thisAccount = (dataSource as? NotificationFeedFilter)?.account
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed(cards)
}
}
private fun convertToCard(notes: List<Note>): List<Card> {
private fun convertToCard(notes: Collection<Note>): List<Card> {
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is ReactionEvent }
@@ -78,7 +80,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
// val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerUser = mutableMapOf<User, MutableMap<Note, Note>>()
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
notes
.filter { it.event is LnZapEvent }
@@ -89,6 +91,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
if (zapRequest != null) {
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
}
} else {
val event = (zapEvent.event as LnZapEvent)
val author = event.zappedAuthor().mapNotNull {
LocalCache.checkGetOrCreateUser(
it
)
}.firstOrNull()
if (author != null) {
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
zapsPerUser.getOrPut(author, { mutableMapOf() })
.put(zapRequest, zapEvent)
}
}
}
}
@@ -121,6 +137,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}.flatten()
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent) {
MessageSetCard(it)
@@ -131,13 +154,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
@@ -150,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
val filteredNewList = localFilter.applyFilter(newItems)
val actuallyNew = filteredNewList.minus(lastNotesCopy)
val newCards = convertToCard(actuallyNew)
if (newCards.isNotEmpty()) {
lastNotes = lastNotesCopy + newItems
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
// Refresh Everything
refreshSuspended()
}
}
@OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
@@ -159,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
invalidateData()
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val (value, elapsed) = measureTimedValue {
refreshFromOldState(it.flatten().toSet())
}
Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed")
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
init {
@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
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.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
@@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
fun refreshSuspended() {
val notes = newListFromDataSource()
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
@@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private fun updateFeed(notes: List<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
@@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
updateFeed(newList)
} else {
// Refresh Everything
refreshSuspended()
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
invalidateData()
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
refreshFromOldState(it.flatten().toSet())
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
init {
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
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.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
@@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun updateFeed(notes: List<Pair<Note, Note>>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty }
} else if (currentState is LnZapFeedState.Loaded) {
@@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData()
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
@@ -296,8 +297,9 @@ fun NoteMaster(
}
}
Spacer(modifier = Modifier.height(10.dp))
if (noteEvent is BadgeDefinitionEvent) {
Spacer(modifier = Modifier.padding(top = 10.dp))
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
@@ -363,7 +365,7 @@ fun NoteMaster(
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
if (noteEvent is PollNoteEvent) {
PollNote(
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
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.model.User
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
@@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
private fun updateFeed(notes: List<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else if (currentState is UserFeedState.Loaded) {
@@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData()
}
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -52,7 +53,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
@@ -60,7 +61,14 @@ class AccountViewModel(private val account: Account) : ViewModel() {
return
}
val zapRequest = account.createZapRequestFor(note, pollOption, message)
var zapRequestJson = ""
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
onProgress(0.10f)
@@ -68,7 +76,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
lud16,
amount,
message,
zapRequest?.toJson(),
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
@@ -170,7 +178,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
}
fun isLoggedUser(user: User?): Boolean {
return account.userProfile() == user
return account.userProfile() === user
}
fun isFollowing(user: User?): Boolean {
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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
@@ -18,10 +20,6 @@ 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
@@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
}
)
}
HorizontalPager(count = 2, state = pagerState) {
HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
@@ -88,6 +89,8 @@ fun ChannelScreen(
val context = LocalContext.current
val channelScreenModel: NewPostViewModel = viewModel()
channelScreenModel.account = account
if (account != null && channelId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) }
@@ -213,7 +216,9 @@ fun ChannelScreen(
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
val tagger = NewMessageTagger(channel, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text)
tagger.run()
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
@@ -14,7 +17,6 @@ import androidx.compose.material.IconButton
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.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
@@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
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.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
@@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val pagerState = rememberPagerState()
@@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
Box(Modifier.fillMaxWidth()) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
}
}
HorizontalPager(count = 2, state = pagerState) {
HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabKnown(accountViewModel, navController, markKnownAsRead)
1 -> TabNew(accountViewModel, navController, markNewAsRead)
@@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val context = LocalContext.current
val chatRoomScreenModel: NewPostViewModel = viewModel()
chatRoomScreenModel.account = account
if (account != null && userId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) }
@@ -177,7 +179,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.getValue
@@ -17,17 +19,13 @@ 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.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
}
)
}
HorizontalPager(count = 1, state = pagerState) {
HorizontalPager(pageCount = 1, state = pagerState) {
when (pagerState.currentPage) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
}
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
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.DisposableEffect
@@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
@@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
@@ -90,13 +88,7 @@ fun HomeScreen(
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -114,7 +106,7 @@ fun HomeScreen(
}
)
}
HorizontalPager(count = 2, state = pagerState) {
HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -19,6 +20,7 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
@@ -34,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
val coroutineScope = rememberCoroutineScope()
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState(
@@ -64,6 +68,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
},
drawerContent = {
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
BackHandler(enabled = scaffoldState.drawerState.isOpen) {
coroutineScope.launch { scaffoldState.drawerState.close() }
}
},
floatingActionButton = {
FloatingButtons(navController, accountStateViewModel)
@@ -1,8 +1,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
@@ -27,6 +31,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
@@ -39,15 +44,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
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.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -66,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
@@ -94,7 +97,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -183,12 +186,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
edgePadding = 8.dp,
modifier = Modifier.onSizeChanged {
tabsSize = it
@@ -264,7 +261,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
}
}
HorizontalPager(
count = 8,
pageCount = 8,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
@@ -405,8 +402,9 @@ private fun ProfileHeader(
}
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false })
val profilePic = baseUser.profilePicture()
if (zoomImageDialogOpen && profilePic != null) {
ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false })
}
}
@@ -421,6 +419,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Row(verticalAlignment = Alignment.Bottom) {
user.bestDisplayName()?.let {
@@ -493,7 +492,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
@@ -555,9 +554,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
if (zapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16, baseUser.pubkeyHex, account) {
zapExpanded = false
}
InvoiceRequest(
lud16,
baseUser.pubkeyHex,
account,
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it)
} else {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
}
},
onClose = {
zapExpanded = false
}
)
}
}
}
@@ -634,6 +649,7 @@ fun BadgeThumb(
if (image == null) {
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.width(size)
@@ -643,6 +659,7 @@ fun BadgeThumb(
} else {
RobohashFallbackAsyncImage(
robot = note.idHex,
robotSize = size,
model = image,
contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier
@@ -691,7 +708,7 @@ private fun DrawBanner(baseUser: User) {
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false })
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
}
} else {
Image(
@@ -158,7 +158,7 @@ fun LoginPage(
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
@@ -237,7 +237,7 @@ fun LoginPage(
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
+21
View File
@@ -263,9 +263,30 @@
<string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string>
<string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string>
<string name="post_poll">Szavazás Létrehozása</string>
<string name="poll_heading_required">Szükséges mezők:</string>
<string name="poll_zap_recipients">Zap-et kapják</string>
<string name="poll_primary_description">Szavazás elsődleges leírása…</string>
<string name="poll_option_index">Szavazás %s megoszlása</string>
<string name="poll_option_description">Szavazás opcióinak leírása</string>
<string name="poll_heading_optional">Kiegészítő mezők:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Konszenzus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Szavazás lezárása</string>
<string name="poll_closing_time_days">napok</string>
<string name="poll_is_closed">A szavazásra nem lehet már új szavazatot leadni</string>
<string name="poll_zap_amount">Zap összege</string>
<string name="one_vote_per_user_on_atomic_votes">Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett</string>
<string name="looking_for_event">"%1$s esemény keresése"</string>
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
</resources>
+59
View File
@@ -226,4 +226,63 @@
<string name="account_switch_has_private_key">Heeft privésleutel</string>
<string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string>
<string name="back">Terug</string>
<string name="report_dialog_spam">Spam of oplichting</string>
<string name="report_dialog_profanity">Godslastering of haatdragend gedrag</string>
<string name="report_dialog_impersonation">Kwaadwillige imitatie</string>
<string name="report_dialog_nudity">Naaktheid of grafische inhoud</string>
<string name="report_dialog_illegal">Illegaal gedrag</string>
<string name="report_dialog_blocking_a_user">Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokkeer & verberg gebruiker]]></string>
<string name="report_dialog_report_btn">Misbruik melden</string>
<string name="report_dialog_reminder_public">Alle geplaatste rapporteringen zijn openbaar.</string>
<string name="report_dialog_additional_reason_placeholder">Geef optioneel extra context over uw rapportering...</string>
<string name="report_dialog_additional_reason_label">Extra context</string>
<string name="report_dialog_select_reason_label">Reden</string>
<string name="report_dialog_select_reason_placeholder">Selecteer een reden…</string>
<string name="report_dialog_post_report_btn">Rapporteer</string>
<string name="report_dialog_title">Blokkeer en rapporteer</string>
<string name="block_only">Blokkeren</string>
<string name="bookmarks">Bladwijzers</string>
<string name="private_bookmarks">Privé bladwijzers</string>
<string name="public_bookmarks">PPublieke Bladwijzers</string>
<string name="add_to_private_bookmarks">Toevoegen aan privé bladwijzers</string>
<string name="add_to_public_bookmarks">Toevoegen aan publieke bladwijzers</string>
<string name="remove_from_private_bookmarks">Verwijderen uit privé bladwijzers</string>
<string name="remove_from_public_bookmarks">Verwijderen uit publieke bladwijzers</string>
<string name="wallet_connect_service">Wallet Connect Service</string>
<string name="wallet_connect_service_explainer">Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays.</string>
<string name="wallet_connect_service_pubkey">Wallet Connect publieke sleutel</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Toon secret sleutel</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex privésleutel</string>
<string name="pledge_amount_in_sats">Bedrag in sats</string>
<string name="post_poll">Verzend poll</string>
<string name="poll_heading_required">Verplichte velden:</string>
<string name="poll_zap_recipients">Zap ontvangers</string>
<string name="poll_primary_description">Primaire poll beschrijving…</string>
<string name="poll_option_index">Optie %s</string>
<string name="poll_option_description">Poll optie beschrijvingen</string>
<string name="poll_heading_optional">Optionele velden:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Sluit na</string>
<string name="poll_closing_time_days">dagen</string>
<string name="poll_is_closed">Poll is gesloten voor nieuwe stemmen</string>
<string name="poll_zap_amount">Zap bedrag</string>
<string name="one_vote_per_user_on_atomic_votes">Slechts één stem per gebruiker is toegestaan bij dit type peiling.</string>
<string name="looking_for_event">"Zoeken naar event %1$s"</string>
<string name="custom_zaps_add_a_message">Een openbaar bericht toevoegen</string>
<string name="custom_zaps_add_a_message_example">Bedankt voor al jullie werk!</string>
<string name="lightning_create_and_add_invoice">Maken en toevoegen</string>
<string name="poll_author_no_vote">Poll auteurs kunnen niet stemmen in hun eigen poll.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
</resources>
+9
View File
@@ -288,4 +288,13 @@
<string name="custom_zaps_add_a_message">Add a public message</string>
<string name="custom_zaps_add_a_message_example">Thank you for all your work!</string>
<string name="lightning_create_and_add_invoice">Create and Add</string>
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">Image is the same since the post</string>
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
</resources>
@@ -79,7 +79,7 @@ fun TranslatableRichTextViewer(
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
Column() {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
+3 -3
View File
@@ -2,7 +2,7 @@ buildscript {
ext {
fragment_version = "1.5.6"
lifecycle_version = '2.6.1'
compose_ui_version = '1.5.0-alpha01'
compose_ui_version = '1.5.0-alpha02'
nav_version = "2.5.3"
room_version = "2.4.3"
accompanist_version = '0.30.0'
@@ -10,8 +10,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.4.2' apply false
id 'com.android.library' version '7.4.2' apply false
id 'com.android.application' version '8.0.0' apply false
id 'com.android.library' version '8.0.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
}

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