Compare commits

..
148 Commits
Author SHA1 Message Date
Vitor Pamplona 9fe73c7a97 v0.11.4 QR code reading and scanning (any NIP-21 uri) 2023-01-29 00:58:43 -03:00
Vitor Pamplona 9263684031 Support for PubKey QR showing and Scanning. 2023-01-29 00:56:13 -03:00
Vitor Pamplona d1753a59f9 v0.11.3 2023-01-28 12:42:37 -03:00
Vitor Pamplona 31e2e586b7 Adding Block user option in profiles. 2023-01-28 12:42:24 -03:00
Vitor Pamplona 5a502f3b80 v0.11.2 2023-01-27 22:09:45 -03:00
Vitor Pamplona 456d40c58e Declaring observers directly into the Picture Compose 2023-01-27 22:05:22 -03:00
Vitor Pamplona d19a1fa1b2 Refactoring 2023-01-27 22:04:59 -03:00
Vitor Pamplona 59dc19720f Correctly filtering out the channel id from the reply to list 2023-01-27 22:04:45 -03:00
Vitor Pamplona 11cfc90d36 Remove Channel Messages from event datasource 2023-01-27 22:04:25 -03:00
Vitor Pamplona 9f2f0727a0 Increase Global size to 200 2023-01-27 22:04:03 -03:00
Vitor Pamplona 6f9a69b9d4 Migrating pictures and banners to https 2023-01-27 22:03:54 -03:00
Vitor Pamplona 5ad39d7163 Clickable Profile pictures on notifications 2023-01-27 20:29:09 -03:00
Vitor Pamplona acff1316be Only disk is much slower 2023-01-27 20:28:30 -03:00
Vitor Pamplona ee2684a775 Printing memory and disk use for cache 2023-01-27 20:28:01 -03:00
Vitor Pamplona 5f3f80bc97 Waits 10 seconds before reconnecting. 2023-01-27 20:06:34 -03:00
Vitor Pamplona 1e3654396b No more blinking in Feeds 2023-01-27 19:28:59 -03:00
Vitor Pamplona ae82c690ea Refreshing new Channels when showing them up on screen. 2023-01-27 19:28:48 -03:00
Vitor Pamplona a29a0a614b Profile Refactoring 2023-01-27 16:10:13 -03:00
Vitor Pamplona 4d5a40f4ac Avoids any crash in processing events. 2023-01-27 16:10:05 -03:00
Vitor Pamplona 27e12a63d6 Full integration between Channel Messages and Regular notes. 2023-01-27 15:50:17 -03:00
Vitor Pamplona a934b42524 Avoiding concurrent exceptions when looping through messages 2023-01-27 14:50:20 -03:00
Vitor Pamplona c29c14f82b Round input texts 2023-01-27 14:49:46 -03:00
Vitor Pamplona 8a9b14fe5f Cleans up spaces on relay addresses 2023-01-27 14:15:14 -03:00
Vitor Pamplona 68c8c8cd98 v0.11.1 2023-01-27 13:44:23 -03:00
Vitor Pamplona e751c206b2 Keeping image cache in the disk only 2023-01-27 13:42:44 -03:00
Vitor Pamplona 74df3333ef Fixes replying information 2023-01-27 13:41:54 -03:00
Vitor Pamplona b5ee783107 Accepts more Like/Dislike codes 2023-01-27 13:41:42 -03:00
Vitor Pamplona 5452196f42 New root search for the thread view 2023-01-27 13:16:45 -03:00
Vitor Pamplona b7f13f6474 Merge remote-tracking branch 'origin/main' 2023-01-27 12:48:49 -03:00
Vitor Pamplona b4fb623a01 Adding verification tag in the thread view and like/boost notification cards 2023-01-27 12:35:41 -03:00
Vitor Pamplona 88a82b3fbc Requires users to accept terms of use as per Google Play requirement. 2023-01-27 11:53:21 -03:00
Vitor PamplonaandGitHub 3dc09cce11 Updated Privacy Policy to define objectionable content 2023-01-27 09:25:14 -05:00
Vitor Pamplona 7399afa648 v0.11.0 2023-01-26 22:14:44 -03:00
Vitor Pamplona 4e74ca6ed0 Removing BRB due to the crashes. 2023-01-26 22:14:12 -03:00
Vitor Pamplona e20669ab3d Support for Notification Bubbles and Verified Follows 2023-01-26 22:09:56 -03:00
Vitor Pamplona 93033295be Marks following users with a Green verified mark 2023-01-26 13:16:57 -03:00
Vitor Pamplona d8ef083086 v0.10.7 2023-01-26 00:11:05 -03:00
Vitor Pamplona 686dc954d9 v0.10.6 2023-01-25 23:08:27 -03:00
Vitor Pamplona 0ad8176a7c Fixes lost font size/style in the latest RTL change. 2023-01-25 23:05:32 -03:00
Vitor Pamplona 47f89232c1 Fixes relay list loading problems 2023-01-25 22:33:50 -03:00
Vitor Pamplona 5c9b736591 Migrates dependencies to new versions. App now requires Android Electric Eel 2023-01-25 22:33:37 -03:00
Vitor Pamplona 875a518a05 Merge remote-tracking branch 'origin/main' 2023-01-25 19:28:49 -03:00
Vitor Pamplona 22311f39e0 Updating lifecycle to 2.6.0 2023-01-25 19:27:09 -03:00
Vitor Pamplona f6ad33b76a StateWithLifecycle seems to be the wrong option here. 2023-01-25 19:26:48 -03:00
Vitor PamplonaandGitHub 05267c598f Merge pull request #38 from KotlinGeekDev/rtl-fix
RTL Support
2023-01-25 17:25:27 -05:00
Vitor PamplonaandGitHub f988d3e488 Merge pull request #37 from KotlinGeekDev/fix-note-navigation
Fix note navigation behaviour.
2023-01-25 17:24:38 -05:00
Vitor Pamplona bd7a4a8372 Concurrent modification exception bugfix 2023-01-25 19:09:18 -03:00
Vitor Pamplona a9788cfa63 Separating Note observers into different types to avoid full screen refreshes. 2023-01-25 18:54:20 -03:00
KotlinGeekDev 74b0dd3b44 Support for RTL in both TextFields and TextViews. 2023-01-25 18:59:52 +01:00
KotlinGeekDev 88ccb63a80 Fix note navigation behaviour. 2023-01-25 17:39:19 +01:00
Vitor Pamplona 542bd485d0 Fixes notification autoupdating 2023-01-25 13:29:44 -03:00
Vitor Pamplona 4c15420b05 Fixes multiple likes 2023-01-25 13:24:56 -03:00
Vitor Pamplona e4dfb2b974 Fixes https://github.com/vitorpamplona/amethyst/issues/30 2023-01-25 13:01:44 -03:00
Vitor Pamplona 71c847b478 v0.10.5 2023-01-25 12:02:13 -03:00
Vitor Pamplona 5f1ce602ea Smaller buttons in the Profile and Channel to support narrower screens. 2023-01-25 12:01:47 -03:00
Vitor Pamplona 380181fd05 More obvious nsec, npub buttons. 2023-01-25 11:32:09 -03:00
Vitor Pamplona 857a31faf0 Blocks double retweets. 2023-01-25 11:00:17 -03:00
Vitor Pamplona 12e5ebeb84 Upgrading Cameri's relay 2023-01-25 10:53:18 -03:00
Vitor Pamplona 965cdd9304 Fix boosted posts not showing up in the Main Follows feed 2023-01-25 10:46:14 -03:00
Vitor Pamplona e7b6ca1bb2 Fixes likes not showing up in Notifications 2023-01-25 10:40:45 -03:00
Vitor Pamplona 30ab477d7d Fixes channels as hidden users. 2023-01-25 10:40:28 -03:00
Vitor Pamplona 68bb6b3479 Bugfix on updating ln address 2023-01-25 00:07:57 -03:00
Vitor Pamplona ebe9690f1b 0.10.4 2023-01-24 23:28:43 -03:00
Vitor Pamplona aa8993eb56 Splits home screen between Follows and Follows+ Replies 2023-01-24 23:28:34 -03:00
Vitor Pamplona ca9f94eaac Logging failures to reduce the amount of network calls, 2023-01-24 23:09:03 -03:00
Vitor Pamplona f931e8dfa9 Fixing order of relays when using default ones 2023-01-24 23:08:44 -03:00
Vitor Pamplona b32c454303 Keeping the feed up to date if you are looking at the top 2023-01-24 22:53:15 -03:00
Vitor Pamplona e9eb7de24a NIP 19 Support 2023-01-24 21:53:22 -03:00
Vitor Pamplona 654deb5e23 Removes the need to send the scroll to the bottom in chatrooms 2023-01-24 19:52:26 -03:00
Vitor Pamplona cae9502d11 v0.10.3 2023-01-24 16:59:31 -03:00
Vitor Pamplona 687428abc1 Support for Blocking and Reporting Users/Posts 2023-01-24 16:59:21 -03:00
Vitor Pamplona 45ea408877 Updating assets for fastlane 2023-01-23 21:18:16 -03:00
Vitor PamplonaandGitHub c863b6c5e5 Merge pull request #5 from IzzySoft/fastlane
initial fastlane structure
2023-01-23 19:10:29 -05:00
Vitor Pamplona 34dd009246 v0.10.2 2023-01-23 20:58:36 -03:00
Vitor Pamplona e064741148 Moving coroutines from Main to Default, which is more ideal for memory intensive tasks 2023-01-23 20:54:56 -03:00
Vitor Pamplona b6c25cfa46 Not updating observers if the database doesn't change. 2023-01-23 20:54:10 -03:00
Vitor Pamplona 114f064bbf Fixes a bug in updating a list of relays after a bug from nostr-postr 2023-01-23 20:53:37 -03:00
Vitor Pamplona aeb78b293e No more black profile pictures while loading 2023-01-23 20:32:10 -03:00
Vitor Pamplona 2b4a1a3966 Updating dependencies 2023-01-23 20:30:40 -03:00
Vitor Pamplona 582f8e39c0 Releasing a new version to make sure versionCode was updated. 2023-01-23 16:30:18 -03:00
Vitor Pamplona a6c3c5c606 Delete old logs 2023-01-23 15:16:26 -03:00
Vitor Pamplona b641678222 migrating v0l to snort 2023-01-23 15:02:36 -03:00
Vitor Pamplona 33fc47f1aa v.0.10.0 2023-01-23 14:30:33 -03:00
Vitor Pamplona 8d3d543318 Avoids duplicating urls in relay list add. 2023-01-23 14:11:13 -03:00
Vitor Pamplona a47aaab83c Relay Management (View/Edit) 2023-01-23 13:58:06 -03:00
Vitor Pamplona c33f7f615f Avoiding recurring inner note rendering. 2023-01-22 19:07:32 -03:00
Vitor Pamplona 0c2fd3adc0 Doesn't try to link phone numbers smaller than 6 chars 2023-01-22 19:07:06 -03:00
Vitor Pamplona a366809319 Search by partial Hex or full npub/note activated. 2023-01-22 18:35:10 -03:00
Vitor Pamplona 7baef64af9 Single line about me texts in the follow list 2023-01-22 18:34:41 -03:00
Vitor Pamplona 9c04cb0e30 putting synchronized lists into a synchronized method 2023-01-22 18:34:21 -03:00
Vitor Pamplona d13979b4b5 Marking Chat groups as such in the UI 2023-01-22 18:33:38 -03:00
Vitor Pamplona 9f15b87d57 Allowing unloaded Notes to go into the feed and be loaded later 2023-01-22 18:33:22 -03:00
Vitor Pamplona 59305c9b15 Using full pubkeys in User requests 2023-01-22 18:32:22 -03:00
Vitor Pamplona 53eeba210a Start of the work to have following since <date> 2023-01-22 18:31:23 -03:00
Vitor Pamplona 38c17f9024 Unnecessary Regex compile 2023-01-22 18:29:59 -03:00
Vitor Pamplona 0c96d0a3fd 0.9.6 2023-01-21 21:14:19 -03:00
Vitor Pamplona 7be2d08db1 View Count 2023-01-21 21:14:10 -03:00
Vitor PamplonaandGitHub 0a02c66c5a Merge pull request #7 from erjanmx/fix-readme-typo
Fix readme typo
2023-01-21 14:01:12 -05:00
Erjan Kalybek 261eecbcc3 Fix readme typo 2023-01-21 20:00:19 +01:00
Vitor Pamplona f17936f029 Version 0.9.5 2023-01-21 13:53:48 -03:00
Vitor Pamplona d64766516a Removing unnecessary observables 2023-01-21 13:53:38 -03:00
Vitor Pamplona d685219ed9 Remove blinking 2023-01-21 13:53:12 -03:00
Vitor Pamplona bb9217466a Hard Refresh when the button to refresh is pressed. 2023-01-21 13:37:57 -03:00
Vitor Pamplona 80f58875bb Uses cached images instead of reloading them by default 2023-01-21 13:32:22 -03:00
Vitor Pamplona ffe58f3d41 Fixing click home button to go to the top of the home feed. 2023-01-21 13:07:47 -03:00
Vitor Pamplona 733801fddf fixing alignment of the reaction buttons 2023-01-21 12:48:14 -03:00
Vitor Pamplona 5cb6bca224 Fixing concurrent modification exception in reactions and boosts 2023-01-21 12:41:05 -03:00
Vitor Pamplona 790dd778bb Fixing quote of the root image in Messages 2023-01-21 12:40:49 -03:00
Vitor Pamplona a692ad47d6 Spam filter in new private messages 2023-01-21 12:31:23 -03:00
Vitor Pamplona 46a8f81387 Adding more logo versions and store banners 2023-01-21 10:58:54 -03:00
Vitor Pamplona acb07352b7 Updated Screenshots 2023-01-21 10:56:24 -03:00
Vitor PamplonaandGitHub 5268da117c Updating Privacy policy
Adding Data deletion path and contact info
2023-01-21 08:54:46 -05:00
Vitor Pamplona b6c1273ecd v0.9.4 2023-01-21 00:55:25 -03:00
Vitor Pamplona 07f9c6e8c3 keeping other classes to make sure deserialization works. 2023-01-21 00:54:39 -03:00
Vitor Pamplona ea53a15105 Adding a Basic Privacy Policy 2023-01-20 23:56:54 -03:00
Vitor Pamplona 716a150fc3 Changing my mind... keeping the dot in time ago 2023-01-20 23:33:43 -03:00
Vitor Pamplona df3ae43ce7 Adjusting the message screen to correctly display the time aligned to the right. 2023-01-20 23:31:44 -03:00
Vitor Pamplona 09bbb59c61 v0.9.3 2023-01-20 23:05:56 -03:00
Vitor Pamplona 0f731117d4 Seems to solve constant crashing when reconnecting after a failure related to this issue: https://github.com/square/okhttp/issues/7381 2023-01-20 23:01:53 -03:00
Vitor Pamplona 115c1ba082 Edit Profile 2023-01-20 23:00:30 -03:00
Vitor Pamplona 135b24df6c Fixing NPE when socket disconnects after the initial check if == null 2023-01-20 18:57:50 -03:00
Vitor PamplonaandGitHub 207f03bb06 Merge pull request #6 from KotlinGeekDev/fix-navigation-issues
Fix navigation.
2023-01-20 16:48:25 -05:00
KotlinGeekDev e78c7aba85 Fix navigation. 2023-01-20 22:31:19 +01:00
Izzy de6c594d5a initial fastlane structure 2023-01-20 21:58:28 +01:00
Vitor PamplonaandGitHub 41b82ec749 Merge pull request #3 from KotlinGeekDev/reduce-apk-size
Make changes to reduce the release APK/app bundle size.
2023-01-20 14:44:58 -05:00
KotlinGeekDev b915956f57 Make changes to reduce the release APK/app bundle size. 2023-01-20 20:26:46 +01:00
Vitor Pamplona 144b0a7512 v0.9.2:
- Fixes bug when tagging new users on reply
- Fixes bug on likes not being shown.
- Fixes alignment of the time in the home feed.
2023-01-19 21:48:15 -05:00
Vitor Pamplona 359eb0b000 Downloading just the last metadata for users being displayed in the screen. 2023-01-19 21:40:24 -05:00
Vitor Pamplona e47476129f BugFix for the position of time in the feed. 2023-01-19 21:39:43 -05:00
Vitor Pamplona 0017845de2 Fixing the bug that wasn't showing likes 2023-01-19 21:35:57 -05:00
Vitor Pamplona ab0bab0c16 Solving User tagging bug 2023-01-19 21:35:36 -05:00
Vitor Pamplona b9b1da1f04 v0.9.1 2023-01-19 18:26:12 -05:00
Vitor Pamplona 0e22a25d3d Removing double call refresh 2023-01-19 18:14:56 -05:00
Vitor Pamplona 8b61dc09d0 Showing likes for Public Chat events. 2023-01-19 18:14:42 -05:00
Vitor Pamplona e1fa46290e Correctly shows Chat notifications in the Notifications Tab. 2023-01-19 17:58:35 -05:00
Vitor Pamplona ec5f510264 Shows replies in chat 2023-01-19 17:58:01 -05:00
Vitor Pamplona 2d46b17493 Block like breaks in the User Information block of the layout. 2023-01-19 17:57:43 -05:00
Vitor Pamplona 99f478d891 Fixing large usernames breaking the layout 2023-01-19 17:56:44 -05:00
Vitor Pamplona 451137e8fa handling co-routines with viewModel Scope 2023-01-19 17:56:22 -05:00
Vitor Pamplona fc88c2867d Adding replying information for notes in a channel 2023-01-19 17:55:57 -05:00
Vitor Pamplona 98704bc43d remove transformations 2023-01-19 17:55:37 -05:00
Vitor Pamplona c5c8ffc70f Avoid using trasnformations. They won't work in this application (base object never changes) 2023-01-19 17:55:25 -05:00
Vitor Pamplona 94a228d78a Simplifying the name of the "Add Image from Gallery" button 2023-01-19 17:54:30 -05:00
Vitor Pamplona 625bbaf35c Marking relays with failure as not connected 2023-01-19 17:54:07 -05:00
Vitor Pamplona dd35e01f8a Fixing null pointer exceptions 2023-01-19 17:53:51 -05:00
Vitor Pamplona c1d46dcc2f Using short-term co-routines 2023-01-19 17:52:32 -05:00
Vitor Pamplona 85c66279b2 Removing unnecessary code 2023-01-19 17:51:35 -05:00
Vitor Pamplona a18c5b975f Solving some of the Out of Memory errors. 2023-01-19 17:50:29 -05:00
115 changed files with 4755 additions and 1091 deletions
+13
View File
@@ -0,0 +1,13 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
+1 -1
View File
@@ -19,7 +19,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Public Chats (NIP-28)
- [ ] Notification Bubbles
- [ ] Infinity Scroll
- [ ] Dropdown to Link Users/Posts when writting
- [ ] Dropdown to Link Users/Posts when writing
- [ ] Identity Verification (NIP-05)
- [ ] Event Delegation (NIP-09)
- [ ] Filter messages from Unknown People
+34 -24
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 11
versionName "0.9"
versionCode 30
versionName "0.11.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,8 +22,8 @@ android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//minifyEnabled false
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
@@ -37,7 +37,7 @@ android {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
kotlinCompilerExtensionVersion '1.4.0'
}
packagingOptions {
resources {
@@ -48,29 +48,30 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.3.1'
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha04'
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation "androidx.compose.material:material-icons-extended:1.3.1"
// Input
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation 'androidx.compose.material:material:1.4.0-alpha05'
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -79,35 +80,44 @@ dependencies {
exclude module: 'guava-testlib'
}
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:0.28.0"
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// upload pictures:
implementation "com.google.accompanist:accompanist-permissions:0.28.0"
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
// view gifs
implementation "io.coil-kt:coil-gif:2.2.2"
// view svgs
implementation("io.coil-kt:coil-svg:2.2.2")
// 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"
// For QR generation
implementation "com.google.zxing:core:3.5.0"
implementation "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
implementation 'com.google.mlkit:vision-common:17.3.0'
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+11 -1
View File
@@ -18,4 +18,14 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
-keepattributes InnerClasses
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
-keep class com.google.gson.reflect.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
+17 -2
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
@@ -14,21 +15,35 @@
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
<activity
android:label="Amethyst"
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask" android:allowTaskReparenting="true"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
</application>
</manifest>
@@ -4,9 +4,9 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class KeyStorage {
class EncryptedStorage {
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
fun preferences(context: Context): EncryptedSharedPreferences {
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
val preferencesName = "secret_keeper"
@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.navigation.Route
import nostr.postr.Persona
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers
)
} else {
return null
}
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
putLong("last_read_route_${route}", timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
return getLong("last_read_route_${route}", 0)
}
}
}
@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.model.Note
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
invalidateData()
}
}
}
fun load(route: String, context: Context): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)
@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
this.account = account
start()
}
fun start() {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
NostrChannelDataSource.account = myAccount
NostrUserProfileDataSource.account = myAccount
NostrUserProfileFollowsDataSource.account = myAccount
NostrUserProfileFollowersDataSource.account = myAccount
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect()
}
}
fun pause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -128,7 +129,7 @@ object LnInvoiceUtil {
}
private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier) {
return when (multiplier.toLowerCase()) {
"m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001")
@@ -8,12 +8,20 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -23,8 +31,11 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
var seeReplies: Boolean = true
class Account(
val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = mutableSetOf()
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
@@ -34,14 +45,52 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows,
relays,
loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event)
LocalCache.consume(event)
}
fun sendNewUserMetadata(toString: String) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
Client.send(event)
LocalCache.consume(event)
}
}
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() } != null) {
if (note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+" } != null) {
// has already liked this note
return
}
@@ -53,9 +102,35 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun report(note: Note) {
if (!isWriteable()) return
if (
note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null
) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun boost(note: Note) {
if (!isWriteable()) return
val currentTime = Date().time / 1000
if (
note.boosts.firstOrNull { it.author == userProfile() && (it?.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
) {
// has already bosted in the past 5mins
return
}
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
@@ -72,12 +147,19 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!)
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
relays,
loggedIn.privKey!!
)
}
Client.send(event)
@@ -87,52 +169,44 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun sendPost(message: String, originalNote: Note?, modifiedMentions: List<User>?) {
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val replyToEvent = originalNote?.event
if (replyToEvent is TextNoteEvent) {
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter {
it in modifiedMentionsHex
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesTo,
mentions = mentions,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
} else {
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = ChannelMessageEvent.create(
message = message,
channel = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
@@ -154,7 +228,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
LocalCache.consume(signedEvent)
}
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) {
fun sendCreateNewChannel(name: String, about: String, picture: String) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
@@ -169,17 +243,27 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex(), accountStateViewModel)
joinChannel(event.id.toHex())
}
fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
fun joinChannel(idHex: String) {
followingChannels.add(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
invalidateData()
}
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
fun leaveChannel(idHex: String) {
followingChannels.remove(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers.add(pubkeyHex)
invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers.remove(pubkeyHex)
invalidateData()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -231,11 +315,54 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
Client.disconnect()
Client.connect(activeRelays() ?: Constants.defaultRelays)
RelayPool.requestAndWatch()
}
})
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post
&& note.reports.filter { it.author in userProfile().follows }.size < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is RepostEvent
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } != null)
) // is not a reaction about a blocked post
}
}
@@ -243,14 +370,6 @@ class AccountLiveData(private val account: Account): LiveData<AccountState>(Acco
fun refresh() {
postValue(AccountState(account))
}
override fun onActive() {
super.onActive()
}
override fun onInactive() {
super.onInactive()
}
}
class AccountState(val account: Account)
@@ -1,13 +1,10 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent
class Channel(val id: ByteArray) {
val idHex = id.toHexKey()
@@ -21,12 +18,8 @@ class Channel(val id: ByteArray) {
val notes = ConcurrentHashMap<HexKey, Note>()
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
}
fun addNote(note: Note) {
notes[note.idHex] = note
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
@@ -60,6 +53,16 @@ class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState
fun refresh() {
postValue(ChannelState(channel))
}
override fun onActive() {
super.onActive()
NostrSingleEventDataSource.add(channel.idHex)
}
override fun onInactive() {
super.onInactive()
NostrSingleEventDataSource.remove(channel.idHex)
}
}
class ChannelState(val channel: Channel)
@@ -26,8 +26,6 @@ fun HexKey.toDisplayHexKey(): String {
}
fun decodePublicKey(key: String): ByteArray {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -19,6 +20,7 @@ import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
@@ -80,6 +82,7 @@ object LocalCache {
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -109,10 +112,10 @@ object LocalCache {
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
// Counts the replies
@@ -139,18 +142,29 @@ object LocalCache {
getOrCreateUser(pubKey)
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println(event.toJson())
println("UpdateFollows: " + event.toJson())
e.printStackTrace()
null
}
}.filterNotNull(),
}.filterNotNull().toSet(),
event.createdAt
)
user.lastestContactList = event
}
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
e.printStackTrace()
}
refreshObservers()
user.latestContactList = event
}
}
fun consume(event: PrivateDmEvent) {
@@ -200,10 +214,10 @@ object LocalCache {
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
// Counts the replies
@@ -230,22 +244,37 @@ object LocalCache {
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") {
if (
event.content == "" ||
event.content == "+" ||
event.content == "\u2764\uFE0F" || // red heart
event.content == "\uD83E\uDD19" || // call me hand
event.content == "\uD83D\uDC4D" // thumbs up
) {
// Counts the replies
repliesTo.forEach {
it.addReaction(note)
}
}
if (event.content == "!" // nostr_console hide.
|| event.content == "\u26A0\uFE0F" // Warning sign
) {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
}
}
fun consume(event: ChannelCreateEvent) {
Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
@@ -253,15 +282,16 @@ object LocalCache {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = oldChannel.getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
refreshObservers()
}
} else {
// older data, does nothing
}
refreshObservers()
}
fun consume(event: ChannelMetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
@@ -274,15 +304,16 @@ object LocalCache {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = oldChannel.getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
refreshObservers()
}
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
refreshObservers()
}
fun consume(event: ChannelMessageEvent) {
@@ -290,14 +321,20 @@ object LocalCache {
val channel = getOrCreateChannel(event.channel)
val note = channel.getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHex())
channel.addNote(note)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { channel.getOrCreateNote(it) }.toMutableList())
val replyTo = Collections.synchronizedList(
event.replyTos
.map { getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
.toMutableList()
)
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
@@ -306,10 +343,10 @@ object LocalCache {
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
// Counts the replies
@@ -338,7 +375,8 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content?.contains(text) ?: false)
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
}
@@ -10,6 +10,7 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -35,6 +36,8 @@ class Note(val idHex: String) {
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
@@ -43,7 +46,7 @@ class Note(val idHex: String) {
this.mentions = mentions
this.replyTo = replyTo
invalidateData()
invalidateData(live)
}
fun formattedDateTime(timestamp: Long): String {
@@ -75,38 +78,52 @@ class Note(val idHex: String) {
fun addReply(note: Note) {
if (replies.add(note))
invalidateData()
invalidateData(liveReplies)
}
fun addBoost(note: Note) {
if (boosts.add(note))
invalidateData()
invalidateData(liveBoosts)
}
fun addReaction(note: Note) {
if (reactions.add(note))
invalidateData()
invalidateData(liveReactions)
}
fun addReport(note: Note) {
if (reports.add(note))
invalidateData(liveReports)
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
return synchronized(reactions) {
reactions.any { it.author == user }
}
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author == user }
return synchronized(boosts) {
boosts.any { it.author == user }
}
}
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
val liveReactions: NoteLiveData = NoteLiveData(this)
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
fun invalidateData(live: NoteLiveData) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
val failures = ConcurrentHashMap<String, Throwable>()
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
@@ -22,6 +23,11 @@ object UrlCachedPreviewer {
return
}
failures[url]?.let {
callback?.onFailed(it)
return
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
@@ -31,6 +37,7 @@ object UrlCachedPreviewer {
}
override fun onFailed(throwable: Throwable) {
failures.put(url, throwable)
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
@@ -53,8 +60,10 @@ object UrlCachedPreviewer {
// Preload Images? Isn't this too heavy?
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
// Do nothing for now.
} else {
} else if (isValidURL(removedParamsFromUrl)) {
previewInfo(it)
} else {
previewInfo("https://${it}")
}
}
}
@@ -1,10 +1,9 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
@@ -14,6 +13,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
@@ -24,12 +25,16 @@ class User(val pubkey: ByteArray) {
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var lastestContactList: ContactListEvent? = null
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var relays: Map<String, ContactListEvent.ReadWrite>? = null
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
@@ -50,12 +55,16 @@ class User(val pubkey: ByteArray) {
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
}
fun follow(user: User) {
fun follow(user: User, followedAt: Long) {
follows.add(user)
user.followers.add(this)
invalidateData()
user.invalidateData()
listeners.forEach {
it.onFollowsChange()
}
}
fun unfollow(user: User) {
follows.remove(user)
@@ -63,12 +72,21 @@ class User(val pubkey: ByteArray) {
invalidateData()
user.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun addTaggedPost(note: Note) {
taggedPosts.add(note)
updateSubscribers { it.onNewPosts() }
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
return messages[user] ?: run {
val channel = mutableSetOf<Note>()
val channel = Collections.synchronizedSet(mutableSetOf<Note>())
messages[user] = channel
channel
}
@@ -77,9 +95,10 @@ class User(val pubkey: ByteArray) {
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
live.refresh()
updateSubscribers { it.onNewMessage() }
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
}
@@ -87,7 +106,7 @@ class User(val pubkey: ByteArray) {
follows - newFollows
}
toBeAdded.forEach {
follow(it)
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
@@ -96,10 +115,27 @@ class User(val pubkey: ByteArray) {
updatedFollowsAt = updateAt
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
listeners.forEach {
it.onRelayChange()
}
}
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
if (info.picture?.startsWith( "http://") == true) {
info.picture = info.picture?.replaceFirst("http://", "https://")
}
if (info.banner?.startsWith( "http://") == true) {
info.banner = info.banner?.replaceFirst("http://", "https://")
}
invalidateData()
}
@@ -109,17 +145,60 @@ class User(val pubkey: ByteArray) {
}
}
// Observers line up here.
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = messages[user] ?: return false
return synchronized(messagesToUser) {
messagesToUser.firstOrNull { this == it.author } != null
}
}
// Model Observers
private var listeners = setOf<Listener>()
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
abstract class Listener {
open fun onRelayChange() = Unit
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewMessage() = Unit
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
modelHandlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
}
}
// UI Observers line up here.
val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
@@ -6,13 +6,13 @@ object Constants {
val defaultRelays = arrayOf(
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
Relay("wss://brb.io", read = true, write = true),
Relay("wss://nostr.v0l.io", read = true, write = true),
//Relay("wss://brb.io", read = true, write = true),
Relay("wss://relay.snort.social", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
Relay("wss://nostr.oxtr.dev", read = true, write = true),
Relay("wss://nostr-relay.wlvs.space", read = true, write = true),
Relay("wss://eden.nostr.land", read = true, write = true),
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
Relay("wss://nostr.mom", read = true, write = true),
@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
class Nip19 {
enum class Type {
USER, NOTE
}
data class Return(val type: Type, val hex: String)
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:")
if (key != null) {
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return Return(Type.USER, bytes.toHexKey())
}
if (key.startsWith("note")) {
return Return(Type.NOTE, bytes.toHexKey())
}
if (key.startsWith("nprofile")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
if (key.startsWith("nevent")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
}
} catch (e: Throwable) {
e.printStackTrace()
}
return null
}
fun parseTLV(data: ByteArray): Map<Byte, MutableList<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
val t = rest[0]
val l = rest[1]
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
rest = rest.sliceArray(IntRange(2 + l, rest.size-1))
if (v.size < l) continue
if (!result.containsKey(t)) {
result.put(t, mutableListOf())
}
result.get(t)?.add(v)
}
return result
}
}
@@ -13,27 +13,11 @@ import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
resetFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener)
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener)
}
fun createAccountContactListFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 5
)
}
@@ -41,7 +25,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 3
limit = 5
)
}
@@ -59,7 +43,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
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 nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
@@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList()
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
override fun updateChannelFilters() {
@@ -33,9 +33,13 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
val messages = account.userProfile().messages[withUser] ?: return emptyList()
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
val filteredMessages = synchronized(messages) {
messages.filter { account.isAcceptable(it) }
}
return filteredMessages.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
@@ -64,20 +64,21 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
val conversation = messages[it]
if (conversation != null) {
synchronized(conversation) {
conversation.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
} else {
null
}
}
val publicChannels = account.followingChannels().map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
val channelsCreatedByMe = LocalCache.channels.values.filter {
it.creator == account.userProfile()
}.map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
@@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Collections
import java.util.UUID
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
@@ -52,27 +53,31 @@ abstract class NostrDataSource<T>(val debugName: String) {
eventCounter.put(key, 1)
}
//println("AAA ${debugName} ${subscriptionId} ${event.kind}")
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is RepostEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
try {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is RepostEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -136,7 +141,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
fun requestNewChannel(): Channel {
val newChannel = Channel()
val newChannel = Channel(debugName+UUID.randomUUID().toString().substring(0,4))
channels.add(newChannel)
channelIds.add(newChannel.id)
return newChannel
@@ -148,21 +153,28 @@ abstract class NostrDataSource<T>(val debugName: String) {
channelIds.remove(channel.id)
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFilters()
resetFiltersSuspend()
handlerWaiting = false
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
resetFiltersSuspend()
}
}
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
// saves the current content to only update if it changes
@@ -1,23 +1,28 @@
package com.vitorpamplona.amethyst.service
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 nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
limit = 50
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
)
val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {
@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.service
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 nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
override fun updateChannelFilters() {}
}
@@ -3,8 +3,12 @@ package com.vitorpamplona.amethyst.service
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.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -12,20 +16,22 @@ import nostr.postr.toHex
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
resetFilters()
object cacheListener: User.Listener() {
override fun onFollowsChange() {
resetFilters()
}
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener)
account.userProfile().subscribe(cacheListener)
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener)
account.userProfile().unsubscribe(cacheListener)
}
fun createFollowAccountsFilter(): JsonFilter {
@@ -76,7 +82,8 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
@@ -17,10 +18,10 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }
set.filter { it.event != null }.filter { account.isAcceptable(it) }
}
return filtered.sortedBy { it.event!!.createdAt }.reversed()
return filtered.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<JsonFilter>? {
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
JsonFilter(
ids = listOfNotNull(hexToWatch)
),
JsonFilter(
authors = listOfNotNull(hexToWatch)
)
)
}
val searchChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList<Note>()
}
override fun updateChannelFilters() {
searchChannel.filter = createAnythingWithIDFilter()
}
fun search(eventId: String) {
try {
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
decodePublicKey(eventId).toHex()
} else if (eventId.startsWith("note")) {
eventId.bechToBytes().toHex()
} else {
eventId
}
hexToWatch = hex
invalidateFilters()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
}
}
fun clear() {
hexToWatch = null
}
}
@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = channelsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList()
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
}
fun add(eventId: String) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: String) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
}
@@ -2,6 +2,9 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
@@ -12,7 +15,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
@@ -20,25 +23,27 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind
),
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val directEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex.substring(0, 8) }
.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
@@ -46,8 +51,11 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
ids = interestedEvents
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
)
}
@@ -55,9 +63,11 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -2,11 +2,12 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
fun createUserFilter(): List<JsonFilter>? {
@@ -15,18 +16,20 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
return usersToWatch.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
limit = 10
authors = listOf(it),
limit = 1
)
}
}
val userChannel = requestNewChannel()
override fun feed(): List<Note> {
return usersToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
override fun feed(): List<User> {
return synchronized(usersToWatch) {
usersToWatch.map {
LocalCache.users[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -12,7 +12,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
@@ -25,8 +25,11 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val eventsToLoad = eventsToWatch
.map { LocalCache.notes[it] }
val nodes = synchronized(eventsToWatch) {
eventsToWatch.map { LocalCache.notes[it] }
}
val eventsToLoad = nodes
.filterNotNull()
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
@@ -44,9 +47,11 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -54,21 +59,52 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)
searchRoot(it, testedNotes)
else
null
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId] ?: return
val note = LocalCache.notes[noteId]
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
if (note != null) {
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
val threadRoot = note.replyTo?.firstOrNull() ?: note
val threadRoot = searchRoot(note) ?: note
loadDown(threadRoot, thread, threadSet)
loadDown(threadRoot, thread, threadSet)
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
} else {
eventsToWatch.clear()
eventsToWatch.add(noteId)
}
resetFilters()
}
@@ -78,7 +114,9 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
thread.add(note)
threadSet.add(note)
note.replies.forEach {
synchronized(note.replies) {
note.replies.toList()
}.forEach {
loadDown(it, thread, threadSet)
}
}
@@ -1,17 +1,20 @@
package com.vitorpamplona.amethyst.service
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.model.toByteArray
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
user = LocalCache.getOrCreateUser(userId.toByteArray())
resetFilters()
}
@@ -37,7 +40,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.sortedBy { it.event?.createdAt }
notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
}
return sortedNotes.reversed()
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@@ -24,7 +26,7 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.toList()
followers.filter { account.isAcceptable(it) }.toList()
}
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@@ -24,7 +26,11 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
val followChannel = requestNewChannel()
override fun feed(): List<User> {
return user?.follows?.toList() ?: emptyList()
val follows = user?.follows ?: emptyList()
return synchronized(follows) {
follows.filter { account.isAcceptable(it) }.toList()
}
}
override fun updateChannelFilters() {
@@ -19,7 +19,7 @@ class ChannelMessageEvent (
init {
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
replyTos = tags.filter { it.firstOrNull() == "e" && (it.size < 3 || (it.size > 3 && it[3] != "root")) }.mapNotNull { it.getOrNull(1) }
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
@@ -25,6 +25,10 @@ class ReactionEvent (
companion object {
const val kind = 7
fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
@@ -27,10 +27,9 @@ object Client: RelayPool.Listener {
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
fun connect(
relays: Array<Relay> = Constants.defaultRelays
) {
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
}
@@ -1,7 +1,12 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.model.LocalCache
import java.util.Date
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toNpub
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -9,14 +14,20 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
class Relay(
val url: String,
var read: Boolean = true,
var write: Boolean = true
var url: String,
var read: Boolean = true,
var write: Boolean = true
) {
private val httpClient = OkHttpClient()
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var closingTime = 0L
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -30,7 +41,7 @@ class Relay(
}
fun requestAndWatch() {
val request = Request.Builder().url(url).build()
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
@@ -48,6 +59,7 @@ class Relay(
val channel = msg[1].asString
when (type) {
"EVENT" -> {
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
@@ -83,33 +95,51 @@ class Relay(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
t.printStackTrace()
errorCounter++
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure ${url}, ${response?.message}")
//t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
}
socket = httpClient.newWebSocket(request, listener)
}
fun disconnect() {
//httpClient.dispatcher.executorService.shutdown()
closingTime = Date().time / 1000
socket?.close(1000, "Normal close")
socket = null
}
fun sendFilter(requestId: String) {
if (socket == null) {
requestAndWatch()
} else {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
if (read) {
if (socket == null) {
// waits 10 seconds
if (Date().time / 1000 > closingTime + 10) {
requestAndWatch()
}
} else {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket?.send(request)
}
}
}
}
@@ -121,8 +151,10 @@ class Relay(
}
fun send(signedEvent: Event) {
if (write)
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
}
}
fun close(subscriptionId: String){
@@ -3,6 +3,10 @@ package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
@@ -20,6 +24,10 @@ object RelayPool: Relay.Listener {
return relays.filter { it.isConnected() }.size
}
fun getRelay(url: String): Relay? {
return relays.firstOrNull() { it.url == url }
}
fun loadRelays(relayList: List<Relay>? = null){
if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) }
@@ -108,7 +116,10 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -14,28 +15,33 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import coil.memory.MemoryCache
import coil.request.CachePolicy
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.bechToBytes
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val nip19 = Nip19().uriToRoute(intent?.data?.toString())
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
else -> null
}
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
@@ -44,7 +50,9 @@ class MainActivity : ComponentActivity() {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.build()
}
.respectCacheHeaders(false)
.build()
}
setContent {
@@ -53,10 +61,10 @@ class MainActivity : ComponentActivity() {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
AccountStateViewModel(LocalPreferences(applicationContext))
}
AccountScreen(accountViewModel)
AccountScreen(accountViewModel, startingPage)
}
}
}
@@ -66,24 +74,13 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
Client.connect()
ServiceManager.start()
}
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
ServiceManager.pause()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
super.onPause()
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -15,8 +16,12 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -26,10 +31,11 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) {
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
val context = LocalContext.current.applicationContext
postViewModel.load(account, channel, accountStateViewModel)
postViewModel.load(account, channel)
Dialog(
onDismissRequest = { onClose() },
@@ -55,7 +61,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
PostButton(
onPost = {
postViewModel.create()
postViewModel.create(context)
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
@@ -77,7 +83,8 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Spacer(modifier = Modifier.height(15.dp))
@@ -111,6 +118,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10
)
@@ -1,25 +1,26 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
private var accountStateViewModel: AccountStateViewModel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
fun load(account: Account, channel: Channel?) {
this.account = account
if (channel != null) {
originalChannel = channel
@@ -29,21 +30,24 @@ class NewChannelViewModel: ViewModel() {
}
}
fun create() {
if (originalChannel == null)
this.account?.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
accountStateViewModel!!
)
else
this.account?.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
fun create(context: Context) {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
clear()
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -23,6 +24,7 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -35,8 +37,11 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -67,7 +72,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = FocusRequester()
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
@@ -152,7 +157,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary)
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val userSuggestions = postViewModel.userSuggestions
@@ -164,7 +170,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
)
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
}
}
@@ -38,7 +38,13 @@ class NewPostViewModel: ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
replyNote.author?.let { replyUser ->
this.mentions = (replyNote.mentions ?: emptyList()).plus(replyUser)
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
this.account = account
@@ -89,7 +95,12 @@ class NewPostViewModel: ViewModel() {
}.joinToString(" ")
}.joinToString("\n")
account?.sendPost(newMessage, originalNote, mentions)
if (originalNote?.channel != null) {
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
}
message = TextFieldValue("")
urlPreview = null
}
@@ -0,0 +1,369 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
true
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
if (index == 0)
ServerConfigHeader()
ServerConfig(item,
onToggleDownload = {
postViewModel.toggleDownload(it)
},
onToggleUpload = {
postViewModel.toggleUpload(it)
},
onDelete = {
postViewModel.deleteRelay(it)
}
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig() {
postViewModel.addRelay(it)
}
}
}
}
}
@Composable
fun ServerConfigHeader() {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Relay Address",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Errors",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(20.dp))
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.url.removePrefix("wss://"),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = Color.Red
)
}
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun EditableServerConfig(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>("") }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Add a Relay") },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
IconButton(onClick = { read = !read }) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else Color.Red
)
}
IconButton(onClick = { write = !write }) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else Color.Red
)
}
Button(
onClick = {
if (url.isNotBlank()) {
val addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write))
url = ""
write = true
read = true
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Add", color = Color.White)
}
}
}
fun countToHumanReadable(counter: Int) = when {
counter >= 1000000000 -> "${round(counter/1000000000f)}G"
counter >= 1000000 -> "${round(counter/1000000f)}M"
counter >= 1000 -> "${round(counter/1000f)}k"
else -> "$counter"
}
@@ -0,0 +1,94 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account) {
this.account = account
clear()
}
fun create() {
relays.let {
account.sendNewRelayList(it.value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
clear()
}
fun clear() {
_relays.update {
val relayFile = account.userProfile().relays
if (relayFile != null)
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter)
}.sortedBy { it.downloadCount }.reversed()
else
Constants.defaultRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
if (relays.value.filter { it.url == relay.url }.isNotEmpty()) return
_relays.update {
it.plus(relay)
}
}
fun deleteRelay(relay: Relay) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
@@ -0,0 +1,211 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
LaunchedEffect(Unit) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
postViewModel.userName.value.isNotBlank()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
maxLines = 10
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}
@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import java.io.ByteArrayInputStream
import java.io.StringWriter
class NewUserMetadataViewModel: ViewModel() {
private lateinit var account: Account
val userName = mutableStateOf("")
val displayName = mutableStateOf("")
val about = mutableStateOf("")
val picture = mutableStateOf("")
val banner = mutableStateOf("")
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
fun load(account: Account) {
this.account = account
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info.about ?: ""
picture.value = it.info.picture ?: ""
banner.value = it.info.banner ?: ""
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
) as ObjectNode
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value)
currentJson.put("username", userName.value)
currentJson.put("display_name", displayName.value)
currentJson.put("displayName", displayName.value)
currentJson.put("picture", picture.value)
currentJson.put("banner", banner.value)
currentJson.put("website", website.value)
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString())
clear()
}
fun clear() {
userName.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
}
}
@@ -48,14 +48,14 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
showGallerySelect = true
}
) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}
@@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) {
fun NewChannelButton(account: Account) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel)
NewChannelView({ wantsToPost = false }, account = account)
OutlinedButton(
onClick = { wantsToPost = true },
@@ -14,13 +14,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
note: Note,
baesNote: Note,
navController: NavController
) {
val innerNoteState by note.live.observeAsState()
ClickableText(
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toShortenHex()} "),
onClick = { navController.navigate("Note/${innerNoteState?.note?.idHex}") },
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -3,10 +3,14 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
@@ -40,7 +44,7 @@ fun isValidURL(url: String?): Boolean {
}
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accountViewModel: AccountViewModel, navController: NavController) {
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
Column(modifier = Modifier.padding(top = 5.dp)) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
@@ -62,14 +66,17 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches()) {
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else {
Text(text = "$word ")
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
@@ -1,18 +1,36 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
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.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
import kotlinx.coroutines.launch
val bottomNavigationItems = listOf(
Route.Home,
@@ -24,6 +42,7 @@ val bottomNavigationItems = listOf(
@Composable
fun AppBottomBar(navController: NavHostController) {
val currentRoute = currentRoute(navController)
val coroutineScope = rememberCoroutineScope()
Column() {
Divider(
@@ -36,21 +55,31 @@ fun AppBottomBar(navController: NavHostController) {
) {
bottomNavigationItems.forEach { item ->
BottomNavigationItem(
icon = {
Icon(
painter = painterResource(id = item.icon),
null,
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
)
},
icon = { NotifiableIcon(item, currentRoute) },
selected = currentRoute == item.route,
onClick = {
if (currentRoute != item.route) {
navController.navigate(item.route)
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route)
coroutineScope.launch {
if (currentRoute != item.route) {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
}
)
@@ -58,3 +87,52 @@ fun AppBottomBar(navController: NavHostController) {
}
}
}
@Composable
private fun NotifiableIcon(item: Route, currentRoute: String?) {
Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = item.icon),
null,
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
)
// Notification
val dbState = LocalCache.live.observeAsState()
val notifState = NotificationCache.live.observeAsState()
val db = dbState.value
val notif = notifState.value
if (db != null && notif != null) {
if (item.hasNewItems(db.cache, notif.cache)) {
Box(
Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.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)
)
}
}
}
}
}
}
@@ -11,11 +11,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel
accountStateViewModel: AccountStateViewModel,
nextPage: String? = null
) {
NavHost(navController, startDestination = Route.Home.route) {
Routes.forEach {
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController))
}
}
if (nextPage != null) {
navController.navigate(nextPage)
}
}
@@ -1,7 +1,11 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -20,16 +24,24 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.Coil
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -45,6 +57,9 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -59,7 +74,10 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -68,47 +86,98 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var wantsToEditRelays by remember {
mutableStateOf(false)
}
if (wantsToEditRelays)
NewRelayListView({ wantsToEditRelays = false }, account)
Column() {
TopAppBar(
elevation = 0.dp,
backgroundColor = Color(0xFFFFFF),
title = {
Column(
modifier = Modifier
.padding(start = 22.dp, end = 0.dp)
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(
onClick = {
Client.allSubscriptions().map { "${it} ${Client.getSubscriptionFilters(it).joinToString { it.toJson() }}" }.forEach {
Log.d("CURRENT FILTERS", it)
Box() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(
onClick = {
Client.allSubscriptions().map {
"${it} ${
Client.getSubscriptionFilters(it)
.joinToString { it.toJson() }
}"
}.forEach {
Log.d("CURRENT FILTERS", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
println("${imageLoader.diskCache?.size}/${imageLoader.diskCache?.maxSize}")
println("${imageLoader.memoryCache?.size}/${imageLoader.memoryCache?.maxSize}")
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.End,
) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = {
wantsToEditRelays = true
}
)
)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}
},
@@ -123,19 +192,16 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(34.dp).height(34.dp)
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape),
)
}
},
actions = {
Text(
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
IconButton(
onClick = {}, modifier = Modifier
) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -7,6 +8,9 @@ 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.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -16,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface
@@ -23,7 +28,10 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -34,14 +42,35 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontWeight.Companion.W500
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ZoomableAsyncImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.google.zxing.qrcode.encoder.ByteMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
@Composable
fun DrawerContent(navController: NavHostController,
@@ -53,7 +82,7 @@ fun DrawerContent(navController: NavHostController,
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val accountUser = accountUserState?.user ?: return
Surface(
modifier = Modifier.fillMaxWidth(),
@@ -105,6 +134,8 @@ fun DrawerContent(navController: NavHostController,
.weight(1F),
accountStateViewModel
)
BottomContent(accountUser, scaffoldState, navController)
}
}
}
@@ -113,12 +144,11 @@ fun DrawerContent(navController: NavHostController,
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
println("AAA " + accountUser?.profilePicture())
Column(modifier = modifier) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
@@ -162,6 +192,8 @@ fun ListContent(
modifier: Modifier,
accountViewModel: AccountStateViewModel
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) {
LazyColumn() {
item {
@@ -178,14 +210,19 @@ fun ListContent(
thickness = 0.25.dp
)
Column(modifier = modifier.padding(horizontal = 25.dp)) {
Text(
text = "Settings",
fontSize = 18.sp,
fontWeight = W500
)
Row(
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }),
) {
Row(modifier = Modifier.clickable(onClick = {
navController.navigate(Route.Filters.route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Text(
text = "Security Filters",
fontSize = 18.sp,
fontWeight = W500
)
}
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
Text(
text = "Log out",
modifier = Modifier.padding(vertical = 15.dp),
@@ -232,5 +269,73 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
)
}
}
}
@Composable
fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
Column(modifier = Modifier) {
Divider(
modifier = Modifier.padding(top = 15.dp),
thickness = 0.25.dp
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
) {
/*
IconButton(
onClick = {
when (AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_theme),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}*/
Box(modifier = Modifier.weight(1F))
IconButton(onClick = {
dialogOpen = true
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
}
if (dialogOpen) {
ShowQRDialog(user,
onScan = {
dialogOpen = false
coroutineScope.launch {
scaffoldState.drawerState.close()
}
navController.navigate(it)
},
onClose = { dialogOpen = false }
)
}
}
}
@@ -0,0 +1,245 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.ByteMatrix
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
const val QR_MARGIN_PX = 100f
@Composable
fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) {
val qrCode = remember(contents) {
createQrCode(contents = contents)
}
val foregroundColor = MaterialTheme.colors.onSurface
Box(
modifier = modifier
.defaultMinSize(48.dp, 48.dp)
.aspectRatio(1f)
.background(MaterialTheme.colors.background)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
// Calculate the height and width of each column/row
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
// of the number of rows/columns to the width and height
drawQrCodeFinders(
sideLength = size.width,
finderPatternSize = Size(
width = columnWidth * FINDER_PATTERN_ROW_COUNT,
height = rowHeight * FINDER_PATTERN_ROW_COUNT
),
color = foregroundColor
)
// Draw data bits (encoded data part)
drawAllQrCodeDataBits(
bytes = qrCode.matrix,
size = Size(
width = columnWidth,
height = rowHeight
),
color = foregroundColor
)
}
}
}
private typealias Coordinate = Pair<Int, Int>
private fun createQrCode(contents: String): QRCode {
require(contents.isNotEmpty())
return Encoder.encode(
contents,
ErrorCorrectionLevel.Q,
mapOf(
EncodeHintType.CHARACTER_SET to "UTF-8",
EncodeHintType.MARGIN to QR_MARGIN_PX,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q
)
)
}
fun newPath(withPath: Path.() -> Unit) = Path().apply {
fillType = PathFillType.EvenOdd
withPath(this)
}
fun DrawScope.drawAllQrCodeDataBits(
bytes: ByteMatrix,
size: Size,
color: Color,
) {
setOf(
// data bits between top left finder pattern and top right finder pattern.
Pair(
first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0),
second = Coordinate(
first = (bytes.width - FINDER_PATTERN_ROW_COUNT),
second = FINDER_PATTERN_ROW_COUNT
)
),
// data bits below top left finder pattern and above bottom left finder pattern.
Pair(
first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT),
second = Coordinate(
first = bytes.width,
second = bytes.height - FINDER_PATTERN_ROW_COUNT
)
),
// data bits to the right of the bottom left finder pattern.
Pair(
first = Coordinate(
first = FINDER_PATTERN_ROW_COUNT,
second = (bytes.height - FINDER_PATTERN_ROW_COUNT)
),
second = Coordinate(
first = bytes.width,
second = bytes.height
)
)
).forEach { section ->
for (y in section.first.second until section.second.second) {
for (x in section.first.first until section.second.first) {
if (bytes[x, y] == 1.toByte()) {
drawPath(
color = color,
path = newPath {
addRect(
rect = Rect(
offset = Offset(
x = QR_MARGIN_PX + x * size.width,
y = QR_MARGIN_PX + y * size.height
),
size = size
)
)
}
)
}
}
}
}
}
const val FINDER_PATTERN_ROW_COUNT = 7
private const val INTERIOR_EXTERIOR_SHAPE_RATIO = 3f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_EXTERIOR_OFFSET_RATIO = 2f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS = 0.12f
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO = 5f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO = 1f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
/**
* A valid QR code has three finder patterns (top left, top right, bottom left).
*
* @param qrCodeProperties how the QR code is drawn
* @param sideLength length, in pixels, of each side of the QR code
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
*/
internal fun DrawScope.drawQrCodeFinders(
sideLength: Float,
finderPatternSize: Size,
color: Color
) {
setOf(
// Draw top left finder pattern.
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
// Draw top right finder pattern.
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
// Draw bottom finder pattern.
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height))
).forEach { offset ->
drawQrCodeFinder(
topLeft = offset,
finderPatternSize = finderPatternSize,
cornerRadius = CornerRadius.Zero,
color = color
)
}
}
/**
* This func is responsible for drawing a single finder pattern, for a QR code
*/
private fun DrawScope.drawQrCodeFinder(
topLeft: Offset,
finderPatternSize: Size,
cornerRadius: CornerRadius,
color: Color
) {
drawPath(
color = color,
path = newPath {
// Draw the outer rectangle for the finder pattern.
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft,
size = finderPatternSize
),
cornerRadius = cornerRadius
)
)
// Draw background for the finder pattern interior (this keeps the arc ratio consistent).
val innerBackgroundOffset = Offset(
x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO,
y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO
)
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft + innerBackgroundOffset,
size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO
),
cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS
)
)
// Draw the inner rectangle for the finder pattern.
val innerRectOffset = Offset(
x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO,
y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO
)
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft + innerRectOffset,
size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO
),
cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS
)
)
}
)
}
@@ -0,0 +1,167 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.vitorpamplona.amethyst.service.Nip19
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@Composable
fun QrCodeScanner(onScan: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor= Executors.newSingleThreadExecutor()
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
hasCameraPermission = granted
}
)
val analyzer = QRCodeAnalyzer { result ->
result?.let {
try {
val nip19 = Nip19().uriToRoute(it)
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
else -> null
}
if (startingPage != null) {
onScan(startingPage)
}
} catch (e: Throwable) {
// QR can be anythign. do not throw errors.
}
}
}
DisposableEffect(key1 = true) {
launcher.launch(Manifest.permission.CAMERA)
onDispose() {
cameraProviderFuture.get().unbindAll()
cameraExecutor.shutdown()
}
}
Column() {
if (hasCameraPermission) {
AndroidView(
factory = { context ->
val previewView = PreviewView(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner)
}, ContextCompat.getMainExecutor(context))
return@AndroidView previewView
},
modifier = Modifier.weight(1f)
)
}
}
}
fun bindPreview(
analyzer: ImageAnalysis.Analyzer,
previewView: PreviewView,
cameraExecutor: ExecutorService,
cameraProvider: ProcessCameraProvider,
lifecycleOwner: LifecycleOwner
) {
val preview = Preview.Builder().build()
val selector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
preview.setSurfaceProvider(previewView.surfaceProvider)
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(
cameraExecutor,
analyzer
)
cameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
imageAnalysis,
preview
)
}
class QRCodeAnalyzer(
private val onQrCodeScanned: (result: String?) -> Unit
) : ImageAnalysis.Analyzer {
private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
fun scanBarcodes(inputImage: InputImage) {
BarcodeScanning.getClient(scanningOptions).process(inputImage)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
onQrCodeScanned(barcodes[0].displayValue)
}
}
.addOnFailureListener {
it.printStackTrace()
}
}
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
imageProxy.image?.let { image ->
val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees)
scanBarcodes(inputImage)
}
imageProxy.close()
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
@@ -9,12 +10,23 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
@@ -25,13 +37,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: @Composable (LocalCache, NotificationCache) -> Boolean = @Composable { _,_ -> false },
val arguments: List<NamedNavArgument> = emptyList(),
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) {
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } })
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Home : Route("Home", R.drawable.ic_home,
hasNewItems = { _, cache -> homeHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
)
object Search : Route("Search", R.drawable.ic_search,
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}
)
object Notification : Route("Notification", R.drawable.ic_notifications,
hasNewItems = { _, cache -> notificationHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }}
)
object Message : Route("Message", R.drawable.ic_dm,
hasNewItems = { _, cache -> messagesHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}
)
object Filters : Route("Filters", R.drawable.ic_dm,
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }}
)
object Profile : Route("User/{id}", R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
@@ -65,11 +94,58 @@ val Routes = listOf(
Route.Profile,
Route.Note,
Route.Room,
Route.Channel
Route.Channel,
Route.Filters
)
//**
//* Functions below only exist because we have not broken the datasource classes into backend and frontend.
//**
@Composable
public fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route
}
@Composable
private fun homeHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
val lastTimeFollows = cache.load("HomeFollows", context)
val homeFeed = NostrHomeDataSource.feed().take(100)
val hasNewInFollows = homeFeed.filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
}.filter {
(it.event?.createdAt ?: 0) > lastTimeFollows
}.isNotEmpty()
return hasNewInFollows
}
@Composable
private fun notificationHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
val lastTime = cache.load("Notification", context)
return NostrNotificationDataSource.loadTop()
.filter { it.event != null && it.event!!.createdAt > lastTime }
.isNotEmpty()
}
@Composable
private fun messagesHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
return NostrChatroomListDataSource.feed().take(100).filter {
// only for known sources
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.hasSentMessagesTo(it.author)
}.filter {
val lastTime = if (it.channel != null) {
cache.load("Channel/${it.channel!!.idHex}", context)
} else {
cache.load("Room/${it.author?.pubkeyHex}", context)
}
NostrChatroomListDataSource.account.isAcceptable(it) && it.event != null && it.event!!.createdAt > lastTime
}.isNotEmpty()
}
@@ -0,0 +1,151 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
@Composable
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
var presenting by remember { mutableStateOf(true) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
) {
if (presenting) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(120.dp)
.height(120.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
Button(
onClick = { presenting = false },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Scan QR")
}
}
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
}
}
}
}
}
}
}
@@ -26,7 +26,38 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Referenced event not found",
text = "Referenced post not found",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
}
}
}
}
@Composable
fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Post was flagged as inappropriate",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -17,24 +19,39 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column() {
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
)
) {
Row(modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
@@ -65,24 +82,17 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc
if (cardNote?.author != null) {
val userState by cardNote.author!!.live.observeAsState()
AsyncImage(
model = userState?.user?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
userState?.let {
navController.navigate("User/${it.user.pubkeyHex}")
}
})
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}
@@ -1,14 +1,22 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Center
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.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -17,11 +25,21 @@ import androidx.compose.runtime.livedata.observeAsState
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.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -32,8 +50,16 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
@@ -44,25 +70,41 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val channelState by note.channel!!.live.observeAsState()
val channel = channelState?.channel
val description = if (note.event is ChannelCreateEvent) {
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
"Channel created"
} else if (note.event is ChannelMetadataEvent) {
} else if (noteEvent is ChannelMetadataEvent) {
"Channel Information changed to "
} else {
note.event?.content
noteEvent?.content
}
channel?.let {
channel?.let { channel ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context)
else
false
ChannelName(
channelPicture = it.profilePicture(),
channelPicture = channel.profilePicture(),
channelPicturePlaceholder = null,
channelTitle = {
Text(
"${it.info.name}",
fontWeight = FontWeight.Bold
"${channel.info.name}",
fontWeight = FontWeight.Bold,
modifier = it,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Text(
" Public Chat",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + description,
onClick = { navController.navigate("Channel/${it.idHex}") })
hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Channel/${channel.idHex}") })
}
} else {
@@ -82,13 +124,22 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
}
userToComposeOn?.let {
val noteEvent = note.event
userToComposeOn?.let { user ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
else
false
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = { UsernameDisplay(it) },
channelLastTime = note.event?.createdAt,
channelPicture = { UserPicture(user = user, userAccount = accountUser, size = 55.dp) },
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = noteEvent?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
}
}
@@ -97,24 +148,47 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
@Composable
fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelPicturePlaceholder: Painter?,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
onClick: () -> Unit
) {
Column(modifier = Modifier.clickable(onClick = onClick) ) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
ChannelName(
channelPicture = {
AsyncImage(
model = channelPicture,
placeholder = channelPicturePlaceholder,
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
},
channelTitle,
channelLastTime,
channelLastContent,
hasNewMessages,
onClick
)
}
@Composable
fun ChannelName(
channelPicture: @Composable () -> Unit,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
onClick: () -> Unit
) {
Column(modifier = Modifier.clickable(onClick = onClick) ) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
channelPicture()
Column(modifier = Modifier.padding(start = 10.dp),
verticalArrangement = Arrangement.SpaceAround) {
@@ -122,7 +196,7 @@ fun ChannelName(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
channelTitle()
channelTitle(Modifier.weight(1f))
channelLastTime?.let {
Text(
@@ -133,20 +207,29 @@ fun ChannelName(
}
if (channelLastContent != null)
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
else
Text(
"Referenced event not found",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (channelLastContent != null)
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f)
)
else
Text(
"Referenced event not found",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (hasNewMessages) {
NewItemsBubble()
}
}
}
}
@@ -155,4 +238,27 @@ fun ChannelName(
thickness = 0.25.dp
)
}
}
@Composable
fun NewItemsBubble() {
Box(
modifier = Modifier
.padding(start = 3.dp)
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.Center
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.Center)
)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -29,15 +30,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -46,15 +52,20 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
var popupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
} else {
@@ -75,6 +86,17 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
shape = ChatBubbleShapeThem
}
// Mark read
val isNew = routeForLastRead?.run {
val isNew = NotificationCache.load(this, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(this, createdAt, context)
isNew
}
Column() {
Row(
modifier = Modifier.fillMaxWidth(1f).padding(
@@ -87,7 +109,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
) {
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth(0.85f)
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
) {
Surface(
@@ -103,7 +125,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
if (author != accountUser && note.event is ChannelMessageEvent) {
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
@@ -111,6 +133,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
) {
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(25.dp)
@@ -135,6 +158,22 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
}
val replyTo = note.replyTo
if (!innerQuote && replyTo != null && replyTo.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.toSet().mapIndexed { index, note ->
if (note.event != null)
ChatroomMessageCompose(
note,
null,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
@@ -154,23 +193,18 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
RichTextViewer(
eventContent,
note.event?.tags,
note,
accountViewModel,
navController
)
else
RichTextViewer(
"Could Not decrypt the message",
note.event?.tags,
note,
accountViewModel,
navController
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -17,25 +19,40 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current.applicationContext
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {
Row( modifier = Modifier
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
)
) {
Row(modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
@@ -65,24 +82,17 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
if (cardNote?.author != null) {
val userState by cardNote.author!!.live.observeAsState()
AsyncImage(
model = userState?.user?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
userState?.let {
navController.navigate("User/${it.user.pubkeyHex}")
}
})
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}
@@ -8,13 +8,18 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -26,40 +31,100 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.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.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import nostr.postr.events.TextNoteEvent
import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isInnerNote: Boolean = false,
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
var popupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
BlankNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (account?.isAcceptable(note) == false) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val authorState by note.author?.live!!.observeAsState()
val author = authorState?.user ?: return // if it has event, it should have an author
val isNew = routeForLastRead?.run {
val lastTime = NotificationCache.load(this, context)
val createdAt = note.event?.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(this, createdAt, context)
createdAt > lastTime
} else {
false
}
} ?: false
Column(modifier =
modifier.combinedClickable(
onClick = { navController.navigate("Note/${note.idHex}") },
onLongClick = { popupExpanded = true },
)
onClick = {
if (note.event !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
).run {
if (isNew) {
this.background(MaterialTheme.colors.primary.copy(0.12f))
} else {
this
}
}
) {
Row(
modifier = Modifier
@@ -71,38 +136,55 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
// Draws the boosted picture outside the boosted card.
if (!isInnerNote) {
Box(modifier = Modifier.width(55.dp).padding(0.dp)) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
UserPicture(author, navController, account.userProfile(), 55.dp)
// boosted picture
val boostedPosts = note.replyTo
if (note.event is RepostEvent && boostedPosts != null && boostedPosts.isNotEmpty()) {
AsyncImage(
model = boostedPosts[0].author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
.align(Alignment.BottomEnd)
.background(MaterialTheme.colors.background)
.border(2.dp, MaterialTheme.colors.primary, CircleShape)
.clickable(onClick = {
boostedPosts[0].author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
val boostedAuthor = boostedPosts[0].author
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
UserPicture(boostedAuthor, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
}
}
}
}
@@ -110,12 +192,13 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
UsernameDisplay(author, Modifier.weight(1f))
if (note.event !is RepostEvent) {
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
} else {
Text(
@@ -128,13 +211,17 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
} else if (note.event is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
}
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
note.replyTo?.mapIndexed { index, note ->
NoteCompose(
note,
modifier = Modifier.padding(top = 5.dp),
modifier = Modifier,
isInnerNote = true,
accountViewModel = accountViewModel,
navController = navController
@@ -153,9 +240,11 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
} else {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
RichTextViewer(eventContent, note.event?.tags, navController)
ReactionsRowState(note, accountViewModel)
//if (note.event !is ChannelMessageEvent) {
ReactionsRow(note, accountViewModel)
//}
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -170,9 +259,102 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
}
}
@Composable
fun UserPicture(
user: User?,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
user?.let {
navController.navigate("User/${it.pubkeyHex}")
}
}
}
@Composable
fun UserPicture(
user: User?,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) {
val accountState by userAccount.live.observeAsState()
val accountUser = accountState?.user ?: return
Box(
Modifier
.width(size)
.height(size)) {
if (user == null) {
AsyncImage(
model = "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/ohno.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
} else {
val userState by user.live.observeAsState()
val userLive = userState?.user ?: return
AsyncImage(
model = userLive.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${userLive.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = onClick)
else
this
}
)
if (accountUser.isFollowing(userLive) || userLive == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Modifier
.padding(4.dp)
.fillMaxSize()
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
}
}
}
}
}
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
DropdownMenu(
expanded = popupExpanded,
@@ -191,5 +373,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) {
Text("Report Post")
}
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
}
}
@@ -2,32 +2,65 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Visibility
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (Note) -> Unit) {
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val reactionsState by baseNote.liveReactions.observeAsState()
val reactedNote = reactionsState?.note
val boostsState by baseNote.liveBoosts.observeAsState()
val boostedNote = boostsState?.note
val repliesState by baseNote.liveReplies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var popupExpanded by remember { mutableStateOf(false) }
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -35,105 +68,130 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
if (wantsToReplyTo != null)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
Row(modifier = Modifier.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
) {
Row(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = baseNote }
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
}
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.boost(baseNote) }
) {
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_comment),
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
Text(
" ${showCount(note.replies?.size)}",
fontSize = 14.sp,
color = grayTint
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) boost(note) }
) {
if (note.isBoostedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(note.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) reactTo(note) }
) {
if (note.isReactedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
Text(
" ${showCount(note.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.reactTo(baseNote) }
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
) {
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_share),
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(reactedNote?.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
tint = grayTint
)
}
Row(modifier = Modifier.weight(1f)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "View count",
modifier = Modifier.height(24.dp),
colorFilter = ColorFilter.tint(grayTint)
)
}
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { popupExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
}
}
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
fun showCount(size: Int?): String {
@@ -1,20 +0,0 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
if (account == null || note == null) return
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo)
}
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -40,7 +41,9 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val mentionSet = mentions.toSet()
mentionSet.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
@@ -51,13 +54,92 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
onClick = { onUserTagClick(myUser) }
)
if (idx < mentions.size - 2) {
if (idx < mentionSet.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
} else if (idx < mentionSet.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(replyTo, mentions, channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onChannelTagClick = {
navController.navigate("Channel/${it.idHex}")
}
)
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?,
mentions: List<User>?,
baseChannel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
onChannelTagClick: (Channel) -> Unit
) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
FlowRow() {
Text(
"in channel ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
ClickableText(
AnnotatedString("${channel.info.name} "),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
val mentionSet = mentions.toSet()
mentionSet.forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
if (idx < mentionSet.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentionSet.size - 1) {
Text(
" and ",
fontSize = 13.sp,
@@ -16,21 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FollowButton
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${user.pubkeyHex}") }
@@ -44,13 +52,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
top = 10.dp)
) {
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
)
UserPicture(user, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -58,16 +60,23 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
user.info.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (accountState?.account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { accountState?.account?.unfollow(user) }
if (account?.isAcceptable(user) == false) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { accountState?.account?.follow(user) }
FollowButton { account?.follow(user) }
}
}
}
@@ -3,16 +3,21 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.User
@Composable
fun UsernameDisplay(user: User) {
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
@@ -22,12 +27,18 @@ fun UsernameDisplay(user: User) {
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
Text(
user.pubkeyDisplayHex,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
}
@@ -3,13 +3,14 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {
val accountState by accountStateViewModel.accountContent.collectAsState()
Column() {
Crossfade(targetState = accountState) { state ->
@@ -18,10 +19,10 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
LoginPage(accountStateViewModel)
}
is AccountState.LoggedIn -> {
MainScreen(AccountViewModel(state.account), accountStateViewModel)
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)
}
is AccountState.LoggedInViewOnly -> {
MainScreen(AccountViewModel(state.account), accountStateViewModel)
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)
}
}
}
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -14,22 +16,28 @@ import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.MainActivity
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() {
class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
init {
// pulls account from storage.
loadFromEncryptedStorage()?.let {
localPreferences.loadFromEncryptedStorage()?.let {
login(it)
}
}
@@ -49,14 +57,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
Account(Persona(Hex.decode(key)))
}
saveToEncryptedStorage(account)
localPreferences.saveToEncryptedStorage(account)
login(account)
}
fun newKey() {
val account = Account(Persona())
saveToEncryptedStorage(account)
localPreferences.saveToEncryptedStorage(account)
login(account)
}
@@ -66,53 +74,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
else
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
NostrAccountDataSource.account = account
NostrHomeDataSource.account = account
NostrNotificationDataSource.account = account
NostrChatroomListDataSource.account = account
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
viewModelScope.launch(Dispatchers.IO) {
ServiceManager.start(account)
}
}
fun logOff() {
_accountContent.update { AccountState.LoggedOff }
clearEncryptedStorage()
}
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", account.followingChannels) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
if (pubKey != null) {
return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels)
} else {
return null
}
}
localPreferences.clearEncryptedStorage()
}
}
@@ -1,15 +1,19 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
abstract class Card() {
abstract fun createdAt(): Long
abstract fun id(): String
}
class NoteCard(val note: Note): Card() {
override fun createdAt(): Long {
return note.event?.createdAt ?: 0
}
override fun id() = note.idHex
}
class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
@@ -18,6 +22,8 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + createdAt
}
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
@@ -26,11 +32,13 @@ class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + createdAt
}
sealed class CardFeedState {
object Loading: CardFeedState()
class Loaded(val feed: List<Card>): CardFeedState()
class Loaded(val feed: MutableState<List<Card>>): CardFeedState()
object Empty: CardFeedState()
class FeedError(val errorMessage: String): CardFeedState()
}
@@ -4,10 +4,12 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -23,14 +25,12 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
@@ -58,21 +58,12 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
}
}
is CardFeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed) { index, item ->
when (item) {
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
}
}
}
FeedLoaded(
state,
accountViewModel,
navController,
routeForLastRead
)
}
CardFeedState.Loading -> {
LoadingFeed()
@@ -82,3 +73,47 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
}
}
}
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
accountViewModel: AccountViewModel,
navController: NavController,
routeForLastRead: String
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { index, item ->
when (item) {
is NoteCard -> NoteCompose(
item.note,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
}
}
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -27,7 +28,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private var lastNotes: List<Note>? = null
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
@@ -43,7 +44,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
updateFeed((oldNotesState.feed.value + newCards).sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
@@ -83,25 +84,32 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
} else if (currentState is CardFeedState.Loaded) {
// updates the current list
currentState.feed.value = notes
} else {
_feedContent.update { CardFeedState.Loaded(notes) }
_feedContent.update { CardFeedState.Loaded(mutableStateOf(notes)) }
}
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -13,7 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.collectAsState
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
@@ -21,11 +21,10 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
@@ -36,46 +35,36 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
reverseLayout = true,
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController)
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
LaunchedEffect(Unit) {
listState.animateScrollToItem(state.feed.size-1, 0)
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
@@ -4,13 +4,16 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -19,6 +22,8 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
@@ -27,7 +32,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
@@ -56,17 +61,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
FeedLoaded(state, accountViewModel, navController)
}
FeedState.Loading -> {
LoadingFeed()
@@ -76,3 +71,28 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
}
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
navController: NavController
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
ChatroomCompose(
item,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@@ -1,11 +1,12 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
sealed class FeedState {
object Loading : FeedState()
class Loaded(val feed: List<Note>) : FeedState()
class Loaded(val feed: MutableState<List<Note>>) : FeedState()
object Empty : FeedState()
class FeedError(val errorMessage: String) : FeedState()
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
@@ -14,32 +15,35 @@ import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
viewModel.hardRefresh()
isRefreshing = false
}
}
@@ -50,6 +54,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
@@ -64,17 +69,12 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
}
}
FeedLoaded(
state,
routeForLastRead,
accountViewModel,
navController
)
}
FeedState.Loading -> {
LoadingFeed()
@@ -85,6 +85,34 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(
item,
isInnerNote = false,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
fun LoadingFeed() {
Column(
@@ -1,7 +1,8 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -15,10 +16,7 @@ import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -28,69 +26,102 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.TextNoteEvent
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatroomListFeedViewModel: FeedViewModel(NostrChatroomListDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource)
class NostrGlobalFeedViewModel: FeedViewModel(NostrGlobalDataSource)
class NostrThreadFeedViewModel: FeedViewModel(NostrThreadDataSource)
class NostrUserProfileFeedViewModel: FeedViewModel(NostrUserProfileDataSource)
class NostrChatroomListKnownFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: all channels + PMs the account has replied to
return super.newListFromDataSource().filter {
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.hasSentMessagesTo(it.author)
}
}
}
class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no channels + PMs the account has never replied to
return super.newListFromDataSource().filter {
val me = NostrChatroomListDataSource.account.userProfile()
it.channel == null && !me.hasSentMessagesTo(it.author)
}
}
}
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no replies
return dataSource.feed().filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
}.take(100)
}
}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {}
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
refreshSuspended()
}
open fun newListFromDataSource(): List<Note> {
return dataSource.loadTop()
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
fun hardRefresh() {
dataSource.resetFilters()
}
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
val notes = newListFromDataSource()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
updateFeed(notes)
}
}
fun updateFeed(notes: List<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else {
_feedContent.update { FeedState.Loaded(notes) }
}
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
// updates the current list
currentState.feed.value = notes
} else {
_feedContent.update { FeedState.Loaded(mutableStateOf(notes)) }
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is FeedState.Loaded) {
_feedContent.update { FeedState.Loaded(state.feed) }
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -1,15 +1,15 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.relays.RelayPool
class RelayPoolViewModel: ViewModel() {
val connectedRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
val connectedRelaysLiveData: LiveData<Int> = RelayPool.live.map {
it.relays.connectedRelays()
}
val availableRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
val availableRelaysLiveData: LiveData<Int> = RelayPool.live.map {
it.relays.availableRelays()
}
}
@@ -30,23 +30,25 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.collectAsState
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRowState
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
@@ -86,9 +88,9 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
listState.animateScrollToItem(noteIdPositionInThread, 0)
}
val notePosition = state.feed.filter { it.idHex == noteId}.firstOrNull()
val notePosition = state.feed.value.filter { it.idHex == noteId}.firstOrNull()
if (notePosition != null) {
noteIdPositionInThread = state.feed.indexOf(notePosition)
noteIdPositionInThread = state.feed.value.indexOf(notePosition)
}
LazyColumn(
@@ -98,7 +100,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
if (index == 0)
NoteMaster(item, accountViewModel = accountViewModel, navController = navController)
else {
@@ -106,7 +108,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
Row() {
NoteCompose(
item,
Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
modifier = Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
@@ -155,6 +157,9 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
if (note?.event == null) {
BlankNote()
} else {
@@ -173,14 +178,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
}
})
) {
// Draws the boosted picture outside the boosted card.
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
)
UserPicture(user = author, navController, userAccount = account.userProfile(), size = 55.dp)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -201,9 +199,9 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Column() {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
RichTextViewer(eventContent, note.event?.tags, navController)
ReactionsRowState(note, accountViewModel)
ReactionsRow(note, accountViewModel)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.User
sealed class UserFeedState {
object Loading : UserFeedState()
class Loaded(val feed: List<User>) : UserFeedState()
class Loaded(val feed: MutableState<List<User>>) : UserFeedState()
object Empty : UserFeedState()
class FeedError(val errorMessage: String) : UserFeedState()
}
@@ -4,10 +4,12 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -22,13 +24,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
@@ -56,17 +56,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
}
}
is UserFeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.pubkeyHex }) { index, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
FeedLoaded(state, accountViewModel, navController)
}
UserFeedState.Loading -> {
LoadingFeed()
@@ -76,3 +66,24 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
}
}
}
@Composable
private fun FeedLoaded(
state: UserFeedState.Loaded,
accountViewModel: AccountViewModel,
navController: NavController
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { index, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
}
@@ -2,12 +2,15 @@ package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlinx.coroutines.CoroutineScope
@@ -28,12 +31,16 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowersDataSource
)
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
NostrHiddenAccountsDataSource
)
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
@@ -55,32 +62,31 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
fun updateFeed(notes: List<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else if (currentState is UserFeedState.Loaded) {
// updates the current list
currentState.feed.value = notes
} else {
_feedContent.update { UserFeedState.Loaded(notes) }
_feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) }
}
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is UserFeedState.Loaded) {
_feedContent.update { UserFeedState.Loaded(state.feed) }
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -1,21 +1,26 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.model.User
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
val accountLiveData: LiveData<AccountState> = account.live.map { it }
fun reactTo(note: Note) {
account.reactTo(note)
}
fun report(note: Note) {
account.report(note)
}
fun boost(note: Note) {
account.boost(note)
}
@@ -27,4 +32,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun decrypt(note: Note): String? {
return account.decryptContent(note)
}
fun hide(user: User, ctx: Context) {
account.hideUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
fun show(user: User, ctx: Context) {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,9 +15,16 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,16 +38,21 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
@@ -83,7 +96,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/${channelId}")
}
//LAST ROW
@@ -99,6 +112,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
shape = RoundedCornerShape(25.dp),
modifier = Modifier
.weight(1f, true)
.padding(end = 10.dp),
@@ -108,16 +122,21 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMeesage(newPost.value.text, channel.idHex)
account.sendChannelMeesage(newPost.value.text, channel.idHex, null, null)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
}
@@ -135,6 +154,7 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
AsyncImage(
model = channel?.profilePicture(),
placeholder = null,
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
@@ -163,16 +183,18 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
}
}
channel?.let { NoteCopyButton(it) }
Row(modifier = Modifier.height(35.dp).padding(bottom = 3.dp)) {
channel?.let { NoteCopyButton(it) }
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it, accountStateViewModel)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account,channel,accountStateViewModel, navController)
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it)
} else {
JoinButton(account,channel,accountStateViewModel, navController)
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
}
}
}
@@ -194,7 +216,7 @@ private fun NoteCopyButton(
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier.padding(horizontal = 3.dp),
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())) },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -202,21 +224,25 @@ private fun NoteCopyButton(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
) {
Text(text = "note", color = Color.White)
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
)
}
}
@Composable
private fun EditButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel) {
private fun EditButton(account: Account, channel: Channel) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel, channel)
NewChannelView({ wantsToPost = false }, account = account, channel)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { wantsToPost = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -224,41 +250,53 @@ private fun EditButton(account: Account, channel: Channel, accountStateViewModel
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Edit", color = Color.White)
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = "Edits the Channel Metadata"
)
}
}
@Composable
private fun JoinButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
private fun JoinButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.joinChannel(channel.idHex, accountStateViewModel)
account.joinChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Join", color = Color.White)
}
}
@Composable
private fun LeaveButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
private fun LeaveButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.leaveChannel(channel.idHex, accountStateViewModel)
account.leaveChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Leave", color = Color.White)
}
@@ -3,35 +3,96 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
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.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
if (account != null) {
val feedViewModel: NostrChatroomListFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Known")
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "New Requests")
}
)
}
HorizontalPager(count = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabKnown(accountViewModel, navController)
1 -> TabNew(accountViewModel, navController)
}
}
}
}
}
@Composable
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@@ -10,11 +10,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -24,12 +27,17 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
@@ -64,7 +72,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}")
}
//LAST ROW
@@ -79,12 +87,14 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true),
shape = RoundedCornerShape(25.dp),
placeholder = {
Text(
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
@@ -94,7 +104,11 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
}
@@ -116,6 +130,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null) {
NostrHiddenAccountsDataSource.account = account
val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Blocked Users")
}
)
}
HorizontalPager(count = 1, state = pagerState) {
when (pagerState.currentPage) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
}
}
}
}
}
}
@@ -3,39 +3,85 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.NotificationCache
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.Routes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val feedViewModel: NostrHomeFeedViewModel = viewModel()
val feedViewModelReplies: NostrHomeRepliesFeedViewModel = viewModel()
if (accountState != null) {
val feedViewModel: NostrHomeFeedViewModel = viewModel()
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
FeedView(feedViewModel, accountViewModel, navController)
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Follows")
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "Follows + Replies")
}
)
}
HorizontalPager(count = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(feedViewModel, accountViewModel, navController, Route.Home.route + "Follows")
1 -> FeedView(feedViewModelReplies, accountViewModel, navController, Route.Home.route + "FollowsReplies")
}
}
}
}
@@ -11,6 +11,7 @@ import androidx.compose.material.Scaffold
import androidx.compose.material.rememberDrawerState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -27,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
@@ -50,14 +51,14 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
scaffoldState = scaffoldState
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(navController, accountViewModel, accountStateViewModel)
AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage)
}
}
}
@Composable
fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) {
val accountState by accountViewModel.accountContent.collectAsStateWithLifecycle()
val accountState by accountViewModel.accountContent.collectAsState()
if (currentRoute(navController) == Route.Home.route) {
Crossfade(targetState = accountState) { state ->
@@ -85,7 +86,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
// Does nothing.
}
is AccountState.LoggedIn -> {
NewChannelButton(state.account, accountViewModel)
NewChannelButton(state.account)
}
}
}
@@ -12,25 +12,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
if (account != null) {
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route)
}
}
}
@@ -1,14 +1,17 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -19,18 +22,29 @@ 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.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
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.EditNote
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -38,44 +52,48 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipboardManager
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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
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.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
data class TabRowItem(
val title: String,
val screen: @Composable () -> Unit,
)
import nostr.postr.toNsec
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val account = accountState?.account ?: return
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (userId != null && account != null && accountUser != null) {
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
@@ -98,75 +116,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
color = MaterialTheme.colors.background
) {
Column() {
Box {
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
)
}
Column(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom) {
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
Spacer(Modifier.weight(1f))
MessageButton(user, navController)
NPubCopyButton(user)
if (accountUser == user) {
EditButton()
} else {
if (accountUser.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
}
}
}
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(" @${user.bestUsername()}", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f))
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
Divider(modifier = Modifier.padding(top = 6.dp))
}
}
ProfileHeader(user, navController, account, accountUser, accountViewModel)
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
@@ -218,6 +168,139 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
}
}
@Composable
private fun ProfileHeader(
user: User,
navController: NavController,
account: Account,
accountUser: User,
accountViewModel: AccountViewModel
) {
val ctx = LocalContext.current.applicationContext
var popupExpanded by remember { mutableStateOf(false) }
Box {
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
Box(modifier = Modifier
.padding(horizontal = 10.dp)
.size(40.dp)
.align(Alignment.TopEnd)) {
Button(
modifier = Modifier
.size(30.dp)
.align(Alignment.Center),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.background
),
contentPadding = PaddingValues(0.dp)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
)
UserProfileDropDownMenu(user, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
UserPicture(
user, navController, account.userProfile(), 100.dp,
pictureModifier = Modifier.border(
3.dp,
MaterialTheme.colors.background,
CircleShape
)
)
Spacer(Modifier.weight(1f))
Row(modifier = Modifier
.height(35.dp)
.padding(bottom = 3.dp)) {
MessageButton(user, navController)
if (accountUser == user && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(user)
if (accountUser == user) {
EditButton(account)
} else {
if (!account.isAcceptable(user)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
}
}
}
}
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
Divider(modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -228,7 +311,7 @@ fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavC
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
@@ -260,6 +343,31 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Password,
contentDescription = "Copies the Nsec ID (your password) to the clipboard for backup"
)
}
}
@Composable
private fun NPubCopyButton(
user: User
@@ -267,7 +375,9 @@ private fun NPubCopyButton(
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier.padding(horizontal = 3.dp),
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -275,14 +385,20 @@ private fun NPubCopyButton(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
) {
Text(text = "npub", color = Color.White)
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
)
}
}
@Composable
private fun MessageButton(user: User, navController: NavController) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { navController.navigate("Room/${user.pubkeyHex}") },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -300,17 +416,30 @@ private fun MessageButton(user: User, navController: NavController) {
}
@Composable
private fun EditButton() {
private fun EditButton(account: Account) {
var wantsToEdit by remember {
mutableStateOf(false)
}
if (wantsToEdit)
NewUserMetadataView({ wantsToEdit = false }, account)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {},
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToEdit = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Edit", color = Color.White)
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = "Edits the User's Metadata"
)
}
}
@@ -323,7 +452,8 @@ fun UnfollowButton(onClick: () -> Unit) {
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unfollow", color = Color.White)
}
@@ -338,10 +468,61 @@ fun FollowButton(onClick: () -> Unit) {
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Follow", color = Color.White)
Text(text = "Follow", color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun ShowUserButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unblock", color = Color.White)
}
}
@Composable
fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
}
Divider()
if (!account.isAcceptable(user)) {
DropdownMenuItem(onClick = {
user.let {
accountViewModel.show(
it,
context
)
}; onDismiss()
}) {
Text("Unblock User")
}
} else {
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Block User")
}
}
}
}
@@ -15,18 +15,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -38,24 +39,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
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.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SearchButton
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -72,7 +76,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
@@ -84,12 +88,20 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val onlineSearch = NostrSearchEventOrUserDataSource
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.value.text.isNotBlank()
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
}
}
//LAST ROW
Row(
modifier = Modifier
@@ -102,10 +114,15 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
value = searchValue.value,
onValueChange = {
searchValue.value = it
if (it.text.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it.text)
searchResults.value = LocalCache.findUsersStartingWith(it.text)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
},
shape = RoundedCornerShape(25.dp),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
@@ -134,6 +151,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
onlineSearch.clear()
}
) {
Icon(
@@ -142,7 +161,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
}
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
@@ -154,15 +177,14 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
bottom = 10.dp
)
) {
itemsIndexed(searchResults.value, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
navController.navigate("User/${item.pubkeyHex}")
}
itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
itemsIndexed(searchResultsChannels.value, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item ->
ChannelName(
channelPicture = item.profilePicture(),
channelPicturePlaceholder = null,
channelTitle = {
Text(
"${item.info.name}",
@@ -171,10 +193,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") })
}
itemsIndexed(searchResultsNotes.value, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n"+item.idHex }) { index, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
@@ -184,6 +207,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
@Composable
fun UserLine(
baseUser: User,
account: Account,
onClick: () -> Unit
) {
val userState by baseUser.live.observeAsState()
@@ -202,14 +226,7 @@ fun UserLine(
)
) {
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
UserPicture(user, account.userProfile(), 55.dp, Modifier, null)
Column(
modifier = Modifier
@@ -222,7 +239,9 @@ fun UserLine(
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,8 +16,11 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
@@ -30,7 +34,9 @@ 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.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
@@ -43,16 +49,29 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.sendMail
@Composable
fun LoginPage(accountViewModel: AccountStateViewModel) {
val key = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") }
val acceptedTerms = remember { mutableStateOf(false) }
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current
Box(modifier = Modifier.fillMaxSize()) {
ClickableText(
text = AnnotatedString("Generate a new key"),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(20.dp),
onClick = { accountViewModel.newKey() },
onClick = {
if (acceptedTerms.value) {
accountViewModel.newKey()
} else {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
},
style = TextStyle(
fontSize = 14.sp,
textDecoration = TextDecoration.Underline,
@@ -66,19 +85,14 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
val key = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") }
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(300.dp),
modifier = Modifier.size(250.dp),
contentScale = ContentScale.Inside
)
Spacer(modifier = Modifier.height(20.dp))
//Text(text = "Insert your private or public key (view-only)")
Spacer(modifier = Modifier.height(40.dp))
var showPassword by remember {
mutableStateOf(false)
@@ -119,7 +133,32 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
)
if (errorMessage.isNotBlank()) {
Text(
text = "${errorMessage}",
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
ClickableText(
text = AnnotatedString("terms of use"),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
@@ -130,16 +169,30 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
},
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
}
@@ -5,4 +5,8 @@ import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Teal200 = Color(0xFF03DAC5)
val Following = Color(0xFF03DAC5)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue

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