Compare commits

...

98 Commits

Author SHA1 Message Date
Vitor Pamplona
4afcf48392 v0.16.2 2023-02-16 19:43:33 -05:00
Vitor Pamplona
943d4525be Removing favicons from proxy 2023-02-16 19:41:50 -05:00
Vitor Pamplona
de302eb966 Improving error count for relays to also include wrong URLs 2023-02-16 19:41:31 -05:00
Vitor Pamplona
85adc722ae v0.16.1 2023-02-16 19:04:26 -05:00
Vitor Pamplona
c8c48cca32 BugFix: User's relays not coming back after a pause of the app if they have not setup relays through the app yet. 2023-02-16 19:03:11 -05:00
Vitor Pamplona
3f68b42acd Moving Async Image Proxy to components. 2023-02-16 18:25:55 -05:00
Vitor Pamplona
da5c61038d Fixes https://github.com/vitorpamplona/amethyst/issues/129 2023-02-16 08:17:41 -05:00
Vitor Pamplona
e8949f08ae Asks reports when each user/event is seen instead. 2023-02-15 17:20:39 -05:00
Vitor Pamplona
068b5faf7d Remove logs 2023-02-15 17:19:55 -05:00
Vitor Pamplona
7dd01ae5e4 v0.16.0 2023-02-15 12:31:28 -05:00
Vitor Pamplona
3493802a1e Activating Image Proxy + CDN 2023-02-15 12:31:15 -05:00
Vitor Pamplona
9ec9feda28 v0.15.9 2023-02-14 20:34:27 -05:00
Vitor Pamplona
0277fa7364 Fixing the delay in updating the Note tab on the Profile View 2023-02-14 20:33:32 -05:00
Vitor Pamplona
af96f142cd Activating Event's check signature 2023-02-14 20:32:35 -05:00
Vitor Pamplona
f80ba653b2 v0.15.8 2023-02-14 19:23:33 -05:00
Vitor Pamplona
5947d03cb3 Follower Count bug 2023-02-14 19:22:37 -05:00
Vitor Pamplona
694cfdf322 v0.15.7 2023-02-14 18:21:57 -05:00
Vitor Pamplona
10efe5d17d Makes chat bubbles back to their contentWrap state 2023-02-14 18:15:33 -05:00
Vitor Pamplona
d964f93e67 Adds helper classes to avoid recreating master lists. 2023-02-14 17:44:03 -05:00
Vitor Pamplona
4996c5a9d8 Only adds a report if it doesn't already exist in the database 2023-02-14 17:43:19 -05:00
Vitor Pamplona
c0fe13d79c Only wait 5 seconds to update lists if contents are already been displayed. 2023-02-14 17:42:35 -05:00
Vitor Pamplona
c56d3af0fc v0.10.6 2023-02-14 16:13:11 -05:00
Vitor Pamplona
a050236d6a Increasing Delays to update lists to 5 seconds while making sure it refreshes quickly when the user switches the tab. 2023-02-14 16:02:38 -05:00
Vitor Pamplona
90bdeffc52 v0.15.5 Performance Improvements. 2023-02-14 14:26:28 -05:00
Vitor Pamplona
e3ed676624 Avoids using remember for Fixed avatars. 2023-02-14 14:25:26 -05:00
Vitor Pamplona
5e5ea32be1 Better fixed avatar handling. 2023-02-14 14:25:01 -05:00
Vitor Pamplona
1306270887 Update lists once every second, not 100ms 2023-02-14 14:24:50 -05:00
Vitor Pamplona
fbff78208e Making sure screens are refreshed from the start 2023-02-14 14:24:14 -05:00
Vitor Pamplona
8468212440 v0.15.4 2023-02-14 13:35:55 -05:00
Vitor Pamplona
7f5a173259 Adding more information to the debug info 2023-02-14 13:34:59 -05:00
Vitor Pamplona
1b8d83f41a Increasing Image disk cache size to 1GB 2023-02-14 13:34:45 -05:00
Vitor Pamplona
e381e2f05f Adjusting Post Lists to 1000 and simplifying comparisons to know when the list has changed. 2023-02-14 12:08:35 -05:00
Vitor Pamplona
6a05039bde Fixing Zap counter in the Profile Page. 2023-02-14 11:48:17 -05:00
Vitor Pamplona
76737e6b74 Adding Zaps to the profile 2023-02-14 11:37:41 -05:00
Vitor Pamplona
c3aa37534e Removing the Duplicated Observer infrastructure on User: It wasn't updating observers correctly due to the batch update we use. 2023-02-14 11:01:08 -05:00
Vitor Pamplona
8e897762dd Getting Relay icons from non SSL-secured relays 2023-02-14 10:58:42 -05:00
Vitor Pamplona
790cbb35db Moving Database updates to an invalidate UI call that only runs at every 100 ms. 2023-02-14 09:29:24 -05:00
Vitor Pamplona
743c23f3c8 Moving Incoming Payload processing to the Global Thread to release Web Threads faster. 2023-02-14 09:17:12 -05:00
Vitor Pamplona
385a1c3849 Merge pull request #122 from veer66/main
Fix issue #121
2023-02-14 08:25:00 -05:00
Vee Satayamas
a3a3f8ea99 Fix issue #121
NIP-09 is event deletion, not delegation
2023-02-14 10:49:30 +07:00
Vitor Pamplona
25b470cde6 Removing border colors. 2023-02-13 18:10:13 -05:00
Vitor Pamplona
05d71fd27e Better positioning of the User Search (always at the bottom, independent of screen size) 2023-02-13 18:08:38 -05:00
Vitor Pamplona
f5e61b9409 v0.15.3 2023-02-13 17:12:57 -05:00
Vitor Pamplona
620bd4d5a8 This might work best between smaller images and bigger images 2023-02-13 17:12:19 -05:00
Vitor Pamplona
3f671350ce Adjusting the New Post screen to show Search and Images (cropped if necessary) together with the keyboard and the text. 2023-02-13 16:48:42 -05:00
Vitor Pamplona
b89a8e5f8f v0.15.2 2023-02-13 16:14:17 -05:00
Vitor Pamplona
a01a282e2e Adjusting New Post screen's keyboard cutting off in long posts 2023-02-13 16:13:47 -05:00
Vitor Pamplona
2ed0ca5fdb Removing DataSource stops in ViewModels to make sure this is handled by the Screen class 2023-02-13 15:50:50 -05:00
Vitor Pamplona
49366ac47d Speeding up report queries 2023-02-13 15:49:51 -05:00
Vitor Pamplona
2ac4ed8ac4 Merge pull request #118 from Radiokot/fix/gif_image_upload
Fix GIF image upload #115
2023-02-13 09:40:46 -05:00
Oleg Koretsky
bb76bbd313 Show running image uploading. Show error message if the uploading is failed 2023-02-13 16:24:49 +02:00
Vitor Pamplona
2c5ba69657 v0.15.1 2023-02-13 08:54:23 -05:00
Vitor Pamplona
a618e84d32 Image seems to better support Bitmap models than Async Image 2023-02-13 08:45:32 -05:00
Oleg Koretsky
108ad4dadc Do not encode images to JPEG before uploading. Fixes GIF upload #115 2023-02-13 11:57:26 +02:00
Vitor Pamplona
80bede648f Moving the ThreadView to the same Model as Global: Only active when using this view. 2023-02-12 21:53:22 -05:00
Vitor Pamplona
e5ee367761 Doesn't start the Global to make sure home updates faster. 2023-02-12 21:48:07 -05:00
Vitor Pamplona
4e53a9fda0 Removes unnecessary println 2023-02-12 21:47:53 -05:00
Vitor Pamplona
caa170f78e Forces profile screen and search screen to only be active when in such views. 2023-02-12 21:47:38 -05:00
Vitor Pamplona
a857b587a1 Forces the use of a single RoboHash for performance 2023-02-12 21:47:02 -05:00
Vitor Pamplona
420bacea3d Forcing the Account Datasource to only download notifications (Removing new followers, for instance) 2023-02-12 21:46:40 -05:00
Vitor Pamplona
b2da651fd1 Clears filters when DataSource closes 2023-02-12 21:44:18 -05:00
Vitor Pamplona
04d94b4dbf Removes Raw ZapNotes from Notifications 2023-02-12 21:43:19 -05:00
Vitor Pamplona
64f2575228 Lightning Zaps. 2023-02-12 18:23:02 -05:00
Vitor Pamplona
dc07aea525 Merge remote-tracking branch 'origin/main' 2023-02-11 10:34:09 -05:00
Vitor Pamplona
21ac1542ee Add a logo option with borders for the play store. 2023-02-11 10:33:56 -05:00
Vitor Pamplona
9cd90db6b3 Update README.md 2023-02-11 10:16:50 -05:00
Vitor Pamplona
8a65f3fbe7 Moving channels to the local version of RoboHash 2023-02-10 17:29:10 -05:00
Vitor Pamplona
e6153e7ef5 Enabling local RoboHash for User Pictures 2023-02-10 17:14:34 -05:00
Vitor Pamplona
c62c4c7545 Reconnect after 60 seconds instead of 10 seconds. 2023-02-10 17:14:08 -05:00
Vitor Pamplona
c4d3c105f7 Better content descriptors for UrlPreviewCard 2023-02-10 17:13:51 -05:00
Vitor Pamplona
3d3578666d Using a local version of RoboHash 2023-02-10 17:13:25 -05:00
Vitor Pamplona
01c060caa1 Caching videos to reduce initialization delay. 2023-02-10 14:30:39 -05:00
Vitor Pamplona
d6cd8e2804 v0.14.3 2023-02-10 11:56:02 -05:00
Vitor Pamplona
fa559c8428 Disabling ProGuard until we figure out why crashes only happen in the release build. 2023-02-10 11:55:31 -05:00
Vitor Pamplona
a38087a9b9 v0.14.2 2023-02-10 09:13:45 -05:00
Vitor Pamplona
29dc677148 Allows the user to leave and join their own groups 2023-02-10 09:13:01 -05:00
Vitor Pamplona
50bc55e84b More Performant UpdateFollows 2023-02-09 17:52:18 -05:00
Vitor Pamplona
ae8d39df92 Download only metadata in the Search for authors 2023-02-09 16:42:30 -05:00
Vitor Pamplona
09919e99b9 Fixes: https://github.com/vitorpamplona/amethyst/issues/91 2023-02-09 15:25:32 -05:00
Vitor Pamplona
5069c30a4a Fixes https://github.com/vitorpamplona/amethyst/issues/103 Doesn't do anything if the content in the field is not a URI. 2023-02-09 15:11:20 -05:00
Vitor Pamplona
4097263c12 Activating Proguard 2023-02-09 15:02:13 -05:00
Vitor Pamplona
88e91e37fa Fixing the height of the nav bar icon 2023-02-09 14:56:54 -05:00
Vitor Pamplona
a003e111eb Fixing QR Code screen for smaller screens. 2023-02-09 14:45:00 -05:00
Vitor Pamplona
4cde2fe8e6 Refactoring of RelaySetupInfo away from ModelView 2023-02-09 14:25:58 -05:00
Vitor Pamplona
ffe5e79474 Making sure once proguard is used, it doesn't break json serialization 2023-02-09 14:24:56 -05:00
Vitor Pamplona
19a9e7a949 language-id seems necessary for some phones. unsure why 2023-02-09 14:24:28 -05:00
Vitor Pamplona
757e566774 Fixes permissions issue in Image Upload 2023-02-09 11:22:49 -05:00
Vitor Pamplona
7fff535b74 v0.14.1:
- Save image
- LNURL edit in the profile
- Bugfix crashing the Lightning Tips with non numeric values in the amount.
2023-02-09 10:42:08 -05:00
Vitor Pamplona
65ef34511a Adding LNURL setup in the User Metadata 2023-02-09 10:36:43 -05:00
Vitor Pamplona
644c6b4d76 Merge remote-tracking branch 'origin/main' 2023-02-09 09:47:34 -05:00
Vitor Pamplona
dec17e162e Avoids crashing on a non-numeric amount in Lightning Tips 2023-02-09 09:47:23 -05:00
Vitor Pamplona
df49c63936 Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
2023-02-09 09:22:46 -05:00
Vitor Pamplona
f0d4ad38a5 Merge pull request #98 from bleetube/offchain.pub
Public relay name change
2023-02-09 09:22:27 -05:00
Vitor Pamplona
7cb146bb50 v0.14.0 2023-02-08 18:27:23 -05:00
Vitor Pamplona
5195ddf63e Sending Lightning Tips 2023-02-08 18:26:49 -05:00
Oleg Koretsky
3fde6b4b1f Add Save option to the image view screen 2023-02-08 22:06:12 +02:00
Brian Lee
5102a15532 Public relay name change 2023-02-08 12:05:56 -08:00
Vitor Pamplona
00981ef15c Refactoring RichTextViewer file 2023-02-08 14:02:42 -05:00
85 changed files with 2991 additions and 882 deletions

View File

@@ -17,14 +17,16 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] Public Chats (NIP-28)
- [ ] Notification Bubbles
- [x] Notification Bubbles
- [x] Reporting and Hide User capability
- [x] Profile Edit
- [x] Relay Edit (separate relay sets for home, dms, public chats, global)
- [x] Dropdown to Link Users when writing
- [x] Lightning Tips
- [ ] Zaps
- [ ] Infinity Scroll
- [ ] Dropdown to Link Users/Posts when writing
- [ ] Identity Verification (NIP-05)
- [ ] Event Delegation (NIP-09)
- [ ] Filter messages from Unknown People
- [ ] Profile Edit
- [ ] Relay Edit
- [ ] Event Deletion (NIP-09)
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
@@ -123,4 +125,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 39
versionName "0.13.3"
versionCode 55
versionName "0.16.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,7 +22,7 @@ android {
buildTypes {
release {
//minifyEnabled false
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
@@ -93,6 +93,9 @@ dependencies {
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
// Robohash for Avatars
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
@@ -130,6 +133,7 @@ dependencies {
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
testImplementation 'junit:junit:4.13.2'

View File

@@ -26,6 +26,12 @@
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }

View File

@@ -4,8 +4,13 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@@ -4,16 +4,10 @@ import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
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.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LocalPreferences(context: Context) {
@@ -52,8 +46,8 @@ class LocalPreferences(context: Context) {
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>()
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language

View File

@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.util.UUID
import name.neuhalfen.projects.android.robohash.RoboHash
object RoboHashCache {
lateinit var robots: RoboHash
lateinit var defaultAvatar: ImageBitmap
@Synchronized
fun get(context: Context, hash: String): ImageBitmap {
if (!this::robots.isInitialized) {
robots = RoboHash(context)
//robots.useCache(LruCache(100));
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
}
return defaultAvatar
}
}

View File

@@ -29,7 +29,7 @@ object ServiceManager {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.convertLocalRelays())
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
@@ -45,13 +45,13 @@ object ServiceManager {
NostrUserProfileFollowersDataSource.account = myAccount
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
//NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
//NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays

View File

@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
object VideoCache {
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
lateinit var simpleCache: SimpleCache
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
context.cacheDir,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
return cacheDataSourceFactory
}
}

View File

@@ -0,0 +1,148 @@
package com.vitorpamplona.amethyst.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size != 2) {
return null
}
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve User's LNURL address from ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
e.printStackTrace()
}
})
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from ${lnCallback}. Message ${e.message}")
e.printStackTrace()
}
})
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(lnaddress,
onSuccess = {
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
},
onError = onError
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(lnaddress,
onSuccess = {
val lnurlp = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError("Callback URL not found in the User's lightning address server configuration")
}
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
callback?.let { callback ->
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
onSuccess = {
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
null
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
}
},
onError = onError
)
}
},
onError = onError
)
}
}

View File

@@ -1,14 +1,13 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Text
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
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.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -16,12 +15,12 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -53,7 +52,7 @@ class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
) {
@@ -122,6 +121,30 @@ class Account(
}
}
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
if (!isWriteable()) return null
if (note.hasZapped(userProfile())) {
// has already liked this note
return null
}
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
return null
}
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user.pubkeyHex)
}
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
@@ -372,13 +395,13 @@ class Account(
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
userProfile().liveRelays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
Client.disconnect()
Client.connect(activeRelays() ?: convertLocalRelays())
RelayPool.requestAndWatch()
}
})
}
}
// Observers line up here.
@@ -405,12 +428,12 @@ class Account(
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& user.reportsBy( userProfile().follows ).size < 5
&& user.reportAuthorsBy( userProfile().follows ).size < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& note.reportsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
&& note.reportAuthorsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
@@ -435,7 +458,7 @@ class Account(
innerReports).toSet()
}
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}

View File

@@ -34,9 +34,9 @@ class Channel(val id: ByteArray) {
live.refresh()
}
fun profilePicture(): String {
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${idHex}.png"
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {

View File

@@ -10,6 +10,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -20,6 +22,12 @@ import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -141,7 +149,7 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
try {
@@ -405,6 +413,72 @@ object LocalCache {
}
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
val zapRequest = event.containedPost?.id?.toHexKey()?.let { getOrCreateNote(it) }
if (zapRequest == null) {
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
//Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
mentions.forEach {
it.addZap(zapRequest, note)
}
}
fun consume(event: LnZapRequestEvent) {
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
mentions.forEach {
it.addZap(note, null)
}
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter {
it.info.anyNameStartsWith(username)
@@ -434,12 +508,30 @@ object LocalCache {
val live: LocalCacheLiveData = LocalCacheLiveData(this)
private fun refreshObservers() {
live.refresh()
live.invalidateData()
}
}
class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(LocalCacheState(cache)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -16,7 +18,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.math.BigDecimal
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
@@ -43,7 +47,9 @@ class Note(val idHex: String) {
private set
var boosts = setOf<Note>()
private set
var reports = setOf<Note>()
var reports = mapOf<User, Set<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
@@ -103,6 +109,16 @@ class Note(val idHex: String) {
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -111,8 +127,13 @@ class Note(val idHex: String) {
}
fun addReport(note: Note) {
if (note !in reports) {
reports = reports + note
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveReports.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveReports.invalidateData()
}
}
@@ -124,6 +145,11 @@ class Note(val idHex: String) {
}
}
fun isZappedBy(user: User): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author == user }
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
}
@@ -132,18 +158,34 @@ class Note(val idHex: String) {
return boosts.any { it.author == user }
}
fun reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24*60*60
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|| reports.isNotEmpty()
return reports.isNotEmpty() ||
(author?.reports?.values?.filter {
it.firstOrNull { it.event?.createdAt ?: 0 > dayAgo } != null
}?.isNotEmpty() ?: false)
}
fun directlyCiteUsers(): Set<User> {
@@ -173,6 +215,10 @@ class Note(val idHex: String) {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
}
@@ -190,21 +236,24 @@ class Note(val idHex: String) {
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
val liveRelays: NoteLiveData = NoteLiveData(this)
val liveZaps: NoteLiveData = NoteLiveData(this)
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}

View File

@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)

View File

@@ -2,21 +2,19 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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
import nostr.postr.toNpub
@@ -42,7 +40,10 @@ class User(val pubkeyHex: String) {
var taggedPosts = setOf<Note>()
private set
var reports = setOf<Note>()
var reports = mapOf<User, Set<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
@@ -51,10 +52,11 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var messages = mapOf<User, Set<Note>>()
data class Chatroom(var roomMessages: Set<Note>)
var privateChatrooms = mapOf<User, Chatroom>()
private set
var latestMetadataRequestEOSE: Long? = null
var latestReportRequestEOSE: Long? = null
fun toBestDisplayName(): String {
@@ -69,9 +71,9 @@ class User(val pubkeyHex: String) {
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
}
fun profilePicture(): String {
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
return info.picture
}
fun follow(user: User, followedAt: Long) {
@@ -80,67 +82,111 @@ class User(val pubkeyHex: String) {
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
it.onFollowsChange()
}
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveFollows.invalidateData()
user.liveFollows.invalidateData()
}
updateSubscribers {
it.onFollowsChange()
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
if (this !in it.followers) {
it.followers = it.followers + this
it.liveFollows.invalidateData()
}
}
liveFollows.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers) {
it.followers = it.followers - this
it.liveFollows.invalidateData()
}
}
liveFollows.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
updateSubscribers { it.onNewTaggedPosts() }
// No need for Listener yet
}
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
updateSubscribers { it.onNewNotes() }
// No need for Listener yet
}
}
fun addReport(note: Note) {
if (note !in reports) {
reports = reports + note
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveReports.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveReports.invalidateData()
}
}
fun reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
@Synchronized
fun getOrCreateChannel(user: User): Set<Note> {
return messages[user] ?: run {
val channel = setOf<Note>()
messages = messages + Pair(user, channel)
channel
fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatroom
}
}
fun addMessage(user: User, msg: Note) {
val channel = getOrCreateChannel(user)
if (msg !in channel) {
messages = messages + Pair(user, channel + msg)
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
liveMessages.invalidateData()
updateSubscribers { it.onNewMessage() }
}
}
@@ -161,35 +207,24 @@ class User(val pubkeyHex: String) {
here.counter++
}
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
}
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
data class RelayMetadata(val read: Boolean, val write: Boolean, val activeTypes: Set<FeedType>)
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
listeners.forEach {
it.onRelayChange()
}
liveRelays.invalidateData()
}
liveRelays.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
@@ -204,57 +239,17 @@ class User(val pubkeyHex: String) {
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = messages[user] ?: return false
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.firstOrNull { this == it.author } != null
return messagesToUser.roomMessages.any { this == it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports.firstOrNull {
it.author == loggedIn
&& it.event is ReportEvent
&& (it.event as ReportEvent).reportType.contains(type)
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type)
} != 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 onNewTaggedPosts() = Unit
open fun onNewNotes() = Unit
open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
modelHandlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
}
}
// UI Observers line up here.
val liveFollows: UserLiveData = UserLiveData(this)
val liveReports: UserLiveData = UserLiveData(this)
@@ -262,6 +257,7 @@ class User(val pubkeyHex: String) {
val liveRelays: UserLiveData = UserLiveData(this)
val liveRelayInfo: UserLiveData = UserLiveData(this)
val liveMetadata: UserLiveData = UserLiveData(this)
val liveZaps: UserLiveData = UserLiveData(this)
}
class UserMetadata {
@@ -292,17 +288,19 @@ class UserMetadata {
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}

View File

@@ -3,6 +3,9 @@ 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 com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -50,6 +53,9 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)

View File

@@ -56,9 +56,9 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser] ?: return emptyList()
val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList()
return messages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
return messages.roomMessages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {

View File

@@ -75,11 +75,11 @@ 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.filter { account.isAcceptable(it) }
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
privateChatrooms[it]?.roomMessages?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {

View File

@@ -3,17 +3,21 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
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.Subscription
import com.vitorpamplona.amethyst.service.relays.hasValidSignature
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
@@ -31,23 +35,28 @@ import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
private var eventCounter = mapOf<String, Int>()
data class Counter(var counter:Int)
private var eventCounter = mapOf<String, Counter>()
fun printCounter() {
eventCounter.forEach {
println("AAA Count ${it.key}: ${it.value}")
println("AAA Count ${it.key}: ${it.value.counter}")
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "${debugName} ${subscriptionId} ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
eventCounter = eventCounter + Pair(key, keyValue + 1)
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, 1)
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
@@ -68,6 +77,14 @@ abstract class NostrDataSource<T>(val debugName: String) {
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
LnZapEvent.kind -> {
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
zapEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(zapEvent)
}
LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(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), relay)
@@ -115,13 +132,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
open fun stop() {
subscriptions.values.forEach { channel ->
if (channel.typedFilters != null) // if it is active, close
if (channel.typedFilters != null) {// if it is active, close
Client.close(channel.id)
channel.typedFilters = null
}
}
}
fun loadTop(): List<T> {
val returningList = feed().take(100)
val returningList = feed().take(1000)
// prepare previews
val scope = CoroutineScope(Job() + Dispatchers.IO)

View File

@@ -2,11 +2,16 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -14,22 +19,26 @@ import nostr.postr.toHex
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
lateinit var account: Account
object cacheListener: User.Listener() {
override fun onFollowsChange() {
resetFilters()
}
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().subscribe(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().liveFollows.observeForever(cacheListener)
}
}
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().unsubscribe(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().liveFollows.removeObserver(cacheListener)
}
}
}
fun createFollowAccountsFilter(): TypedFilter {
@@ -44,7 +53,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
kinds = listOf(TextNoteEvent.kind),
authors = followSet,
limit = 400
)

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.User
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.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
@@ -19,6 +20,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
&& it.event !is LnZapRequestEvent
}
.sortedBy { it.event?.createdAt }
.reversed()

View File

@@ -2,10 +2,13 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
@@ -21,13 +24,16 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)),
ids = listOfNotNull(hexToWatch)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
authors = listOfNotNull(hexToWatch)
))
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
)
}

View File

@@ -5,6 +5,7 @@ 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.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -27,14 +28,14 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime;
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -68,7 +69,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
@@ -77,7 +78,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
)
}
val singleEventChannel = requestNewChannel() { time ->
val singleEventChannel = requestNewChannel { time ->
eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
}

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
@@ -17,7 +18,6 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
fun loadUserProfile(userId: String) {
user = LocalCache.getOrCreateUser(userId)
resetFilters()
}
fun createUserInfoFilter(): TypedFilter {
@@ -42,6 +42,16 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
)
}
fun createUserReceivedZapsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
)
}
fun createFollowFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
@@ -76,7 +86,8 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter()
createFollowersFilter(),
createUserReceivedZapsFilter()
).ifEmpty { null }
}
}

View File

@@ -0,0 +1,25 @@
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.service.model.LnZapEvent
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileZapsDataSource: NostrDataSource<Pair<Note,Note>>("UserProfileZapsFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
override fun feed(): List<Pair<Note,Note>> {
return (user?.zaps?.filter { it.value != null }?.toList()?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }?.reversed() ?: emptyList()) as List<Pair<Note, Note>>
}
override fun updateChannelFilters() {}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
@@ -16,10 +17,11 @@ class ChannelCreateEvent (
@Transient val channelInfo: ChannelData
init {
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
channelInfo = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null)
}
}
@@ -27,10 +29,15 @@ class ChannelCreateEvent (
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = if (channelInfo != null)
gson.toJson(channelInfo)
else
val content = try {
if (channelInfo != null)
gson.toJson(channelInfo)
else
""
} catch (t: Throwable) {
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
""
}
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = emptyList<List<String>>()

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
@@ -20,21 +21,24 @@ class ChannelMetadataEvent (
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
}
channelInfo =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelCreateEvent.ChannelData(null, null, null)
}
}
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
val content = if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val content =
if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )

View File

@@ -0,0 +1,50 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
@Transient val containedPost: Event?
@Transient val lnInvoice: String?
@Transient val preimage: String?
@Transient val amount: BigDecimal?
init {
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
lnInvoice = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
amount = lnInvoice?.let { LnInvoiceUtil.getAmountInSats(lnInvoice) }
preimage = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
val description = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
containedPost = try {
if (description == null)
null
else
fromJson(description, Client.lenient)
} catch (e: Exception) {
null
}
}
companion object {
const val kind = 9735
}
}

View File

@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapRequestEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
init {
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 9734
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf(
listOf("e", originalNote.id.toHex()),
listOf("p", originalNote.pubKey.toHex()),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
]
]
}
*/

View File

@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service.relays
import java.util.UUID
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
@@ -50,7 +52,7 @@ object Client: RelayPool.Listener {
RelayPool.send(signedEvent)
}
fun close(subscriptionId: String){
fun close(subscriptionId: String) {
RelayPool.close(subscriptionId)
}
@@ -61,19 +63,35 @@ object Client: RelayPool.Listener {
}
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
}
fun subscribe(listener: Listener) {

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -14,15 +14,15 @@ object Constants {
val defaultRelays = arrayOf(
// Free relays
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
@@ -35,14 +35,14 @@ object Constants {
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}

View File

@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.service.relays
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.events.Event
import nostr.postr.events.generateId
fun Event.hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
if (!Secp256k1.get().verifySchnorr(sig, id, pubKey)) {
return false
}
return true
}

View File

@@ -22,9 +22,6 @@ class Relay(
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(100, TimeUnit.SECONDS)
.readTimeout(100, TimeUnit.SECONDS)
.callTimeout(100, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build();
@@ -148,6 +145,8 @@ class Relay(
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
errorCounter++
isReady = false
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
@@ -175,8 +174,8 @@ class Relay(
}
}
} else {
// waits 10 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 10) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
// sends all filters after connection is successful.
requestAndWatch()
}
@@ -186,8 +185,11 @@ class Relay(
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}
}
}

View File

@@ -15,6 +15,7 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import com.vitorpamplona.amethyst.EncryptedStorage

View File

@@ -0,0 +1,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}

View File

@@ -1,60 +1,75 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
object ImageUploader {
private fun encodeImage(bitmap: Bitmap): ByteArray {
val byteArrayOutPutStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
return byteArrayOutPutStream.toByteArray()
}
fun uploadImage(
uri: Uri,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit,
) {
val contentType = contentResolver.getType(uri)
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
val client = OkHttpClient.Builder().build()
val body: RequestBody = MultipartBody.Builder()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}.png",
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
}
}
)
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(body)
.post(requestBody)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val body = response.body
if (body != null) {
try {
check(response.isSuccessful)
response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
if (url != null)
onSuccess(url)
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url)
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
})
}

View File

@@ -1,27 +1,15 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
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.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -37,11 +25,9 @@ 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
@@ -49,12 +35,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine
@@ -75,135 +56,152 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
postViewModel.message.text.isNotBlank()
)
}
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
postViewModel.removeFromReplyList(it)
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = "What's on your mind?",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
Surface(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.padding(10.dp).imePadding().weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
) {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.message.text.isNotBlank()
&& !postViewModel.isUploadingImage
)
}
Row(
modifier = Modifier.fillMaxWidth().weight(1f)
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroolState)) {
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
postViewModel.removeFromReplyList(it)
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = "What's on your mind?",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl)
.matches()
) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}
}
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
}
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}

View File

@@ -1,24 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
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.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import nostr.postr.toNpub
class NewPostViewModel: ViewModel() {
@@ -30,6 +25,8 @@ class NewPostViewModel: ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
@@ -104,26 +101,33 @@ class NewPostViewModel: ViewModel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
}
fun upload(it: Uri, context: Context) {
val img = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
}
isUploadingImage = true
img?.let {
ImageUploader.uploadImage(img) {
message = TextFieldValue(message.text + "\n\n" + it)
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
}
}
}
)
}
fun cancel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
}
fun findUrlInMessage(): String? {

View File

@@ -1,25 +1,19 @@
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
@@ -28,18 +22,12 @@ 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.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
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
@@ -48,12 +36,10 @@ 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.platform.LocalContext
import androidx.compose.ui.res.painterResource
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
@@ -62,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@@ -206,16 +193,16 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -376,7 +363,7 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
@@ -422,9 +409,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true

View File

@@ -1,32 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
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,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
@@ -56,7 +44,7 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
@@ -66,12 +54,12 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
@@ -79,46 +67,46 @@ class NewRelayListViewModel: ViewModel() {
}
}
fun deleteRelay(relay: Relay) {
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: Relay) {
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: Relay) {
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: Relay) {
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: Relay) {
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))

View File

@@ -205,6 +205,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}

View File

@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -34,6 +35,7 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
}
@@ -57,6 +59,7 @@ class NewUserMetadataViewModel: ViewModel() {
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
currentJson.put("lud06", lnURL.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -8,12 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -23,10 +19,18 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
fun UploadFromGallery(
isUploading: Boolean,
onImageChosen: (Uri) -> Unit,
) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
@@ -44,18 +48,30 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
modifier = Modifier
.align(Alignment.TopCenter)
.padding(4.dp),
enabled = !isUploading,
onClick = {
showGallerySelect = true
}
) {
Text("Upload Image")
if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Upload Image")
Button(
onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading,
) {
if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
}
}
}

View File

@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.LocalImageLoader
import java.net.URLEncoder
import java.util.Base64
data class ResizeImage(val url: String?, val size: Dp)
@Composable
fun AsyncImageProxy(
model: ResizeImage,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
error: Painter? = null,
fallback: Painter? = error,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
) {
if (model.url == null) {
AsyncImage(
model = model.url,
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val imgPx = with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(model.url.toByteArray())
val extension = model.url.split(".").lastOrNull()
AsyncImage(
model = "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64",
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}

View File

@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, tags, navController)
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
}

View File

@@ -9,13 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.OutlinedTextField
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
@@ -23,11 +30,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
@@ -78,14 +89,15 @@ fun InvoicePreview(lnInvoice: String) {
amount?.let {
Text(
text = "${amount.toInt()} sats",
text = "${NumberFormat.getInstance().format(amount)} sats",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),

View File

@@ -0,0 +1,159 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.MaterialTheme
import androidx.compose.material.OutlinedTextField
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.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.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Tips",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
OutlinedTextField(
label = { Text(text = "Note to Receiver") },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = "Thank you so much!",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
OutlinedTextField(
label = { Text(text = "Amount in Sats") },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = {
runCatching {
if (it.isEmpty())
amount = 0
else
amount = it.toLong()
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message, zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
},
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
}
)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@@ -1,45 +1,24 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.*
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
@@ -62,165 +41,6 @@ fun isValidURL(url: String?): Boolean {
}
}
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo).addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
tags,
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle = SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString= buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
append("-translated from ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset -> annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal")
showOriginal = span.item.toBoolean()
else
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
Text("Always translate to ${lang.displayName}")
}
}
}
}
}
}
}
}
}
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, tags, navController)
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
}
@Composable
fun RichTextViewer(
content: String,
@@ -230,7 +50,6 @@ fun RichTextViewer(
) {
Column(
Modifier
.fillMaxWidth()
.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->

View File

@@ -0,0 +1,151 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Locale
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
tags,
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
append("-translated from ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal")
showOriginal = span.item.toBoolean()
else
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
Text("Always translate to ${lang.displayName}")
}
}
}
}
}
}
}
}
}

View File

@@ -36,7 +36,7 @@ fun UrlPreviewCard(
Column {
AsyncImage(
model = previewInfo.image,
contentDescription = "Profile Image",
contentDescription = "Preview Card Image for ${url}",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)

View File

@@ -13,8 +13,12 @@ import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.vitorpamplona.amethyst.VideoCache
@Composable
fun VideoView(videoUri: String) {
@@ -28,17 +32,17 @@ fun VideoView(videoUri: String) {
}
DisposableEffect(exoPlayer) {
exoPlayer.setMediaItem(MediaItem.fromUri(videoUri))
exoPlayer.setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
)
exoPlayer.prepare()
onDispose {
exoPlayer.release()
}
}
AndroidView(
modifier = Modifier
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer

View File

@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
CloseButton(onCancel = {
dialogOpen = false
})
SaveToGallery(url = word)
}
ZoomableAsyncImage(word)

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -32,10 +34,13 @@ 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.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -43,6 +48,8 @@ import coil.Coil
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
@@ -50,18 +57,24 @@ 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.NostrSearchEventOrUserDataSource
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.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
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 java.net.URLEncoder
import kotlinx.coroutines.launch
@Composable
@@ -72,13 +85,14 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().liveMetadata.observeAsState()
val accountUser = accountUserState?.user
val accountUser = accountUserState?.user ?: return
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
@@ -87,6 +101,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val ctx = LocalContext.current.applicationContext
var wantsToEditRelays by remember {
mutableStateOf(false)
@@ -104,12 +119,14 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box() {
Box(Modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = {
@@ -132,18 +149,24 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
NostrUserProfileZapsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
println("Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
println("${imageLoader.diskCache?.size}/${imageLoader.diskCache?.maxSize}")
println("${imageLoader.memoryCache?.size}/${imageLoader.memoryCache?.maxSize}")
println("Image Disk Cache ${(imageLoader.diskCache?.size ?: 0)/(1024*1024)}/${(imageLoader.diskCache?.maxSize ?: 0)/(1024*1024)} MB")
println("Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0)/(1024*1024)}/${(imageLoader.memoryCache?.maxSize ?: 0)/(1024*1024)} MB")
println("Notes: " + LocalCache.notes.size)
println("Users: " + LocalCache.users.size)
}
) {
Icon(
@@ -190,9 +213,11 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
},
modifier = Modifier
) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
AsyncImageProxy(
model = ResizeImage(accountUser.profilePicture(), 34.dp),
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
contentDescription = "Profile Image",
modifier = Modifier
.width(34.dp)

View File

@@ -71,6 +71,11 @@ import com.google.zxing.qrcode.encoder.ByteMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@Composable
fun DrawerContent(navController: NavHostController,
@@ -124,11 +129,13 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
val accountUserFollowsState by baseAccountUser.liveFollows.observeAsState()
val accountUserFollows = accountUserFollowsState?.user ?: return
val ctx = LocalContext.current.applicationContext
Box {
val banner = accountUser.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
AsyncImageProxy(
model = ResizeImage(banner, 150.dp),
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
@@ -147,10 +154,12 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
}
Column(modifier = modifier) {
AsyncImage(
model = accountUser.profilePicture(),
AsyncImageProxy(
model = ResizeImage(accountUser.profilePicture(), 100.dp),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser.pubkeyHex}.png"),
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
modifier = Modifier
.width(100.dp)
.height(100.dp)

View File

@@ -26,6 +26,7 @@ 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.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
@@ -40,9 +41,12 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -85,7 +89,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
ChannelName(
channelPicture = channel.profilePicture(),
channelPicturePlaceholder = null,
channelPicturePlaceholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
channelTitle = {
Text(
"${channel.info.name}",
@@ -138,7 +142,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
@Composable
fun ChannelName(
channelPicture: String,
channelPicture: String?,
channelPicturePlaceholder: Painter?,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
@@ -148,10 +152,12 @@ fun ChannelName(
) {
ChannelName(
channelPicture = {
AsyncImage(
model = channelPicture,
AsyncImageProxy(
model = ResizeImage(channelPicture, 55.dp),
placeholder = channelPicturePlaceholder,
contentDescription = "Profile Image",
fallback = channelPicturePlaceholder,
error = channelPicturePlaceholder,
contentDescription = "Channel Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -53,12 +54,15 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.RoboHashCache
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.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -151,7 +155,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
) {
val authorState by note.author!!.liveMetadata.observeAsState()
val author = authorState?.user
val author = authorState?.user!!
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
@@ -159,9 +163,11 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
AsyncImageProxy(
model = ResizeImage(author.profilePicture(), 25.dp),
placeholder = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
contentDescription = "Profile Image",
modifier = Modifier
.width(25.dp)
@@ -277,6 +283,7 @@ private fun RelayBadges(baseNote: Note) {
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
val ctx = LocalContext.current.applicationContext
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
@@ -284,9 +291,9 @@ private fun RelayBadges(baseNote: Note) {
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@@ -57,14 +59,18 @@ import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
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.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -96,6 +102,9 @@ fun NoteCompose(
val context = LocalContext.current.applicationContext
var moreActionsExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(modifier.combinedClickable(
onClick = { },
@@ -188,9 +197,11 @@ fun NoteCompose(
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
AsyncImageProxy(
model = ResizeImage(channel.profilePicture(), 30.dp),
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
@@ -228,6 +239,20 @@ fun NoteCompose(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
} else {
Text(
" boosted",
@@ -306,30 +331,37 @@ private fun RelayBadges(baseNote: Note) {
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
val ctx = LocalContext.current.applicationContext
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
.clickable(onClick = { uri.openUri("https://" + url) })
)
}
}
}
if (noteRelays.size > 3 && !expanded) {
Row(Modifier.fillMaxWidth().height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
@@ -373,15 +405,16 @@ fun NoteAuthorPicture(
val author = note.author
val ctx = LocalContext.current.applicationContext
Box(
Modifier
.width(size)
.height(size)) {
if (author == null) {
AsyncImage(
model = "https://robohash.org/ohno.png",
Image(
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
contentDescription = "Unknown Author",
placeholder = rememberAsyncImagePainter("https://robohash.org/ohno.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
@@ -417,17 +450,19 @@ fun UserPicture(
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Box(
Modifier
.width(size)
.height(size)) {
AsyncImage(
model = user.profilePicture(),
AsyncImageProxy(
model = ResizeImage(user.profilePicture(), size),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
error = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)

View File

@@ -1,32 +1,32 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.net.Uri
import android.widget.Toast
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.Bolt
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
@@ -34,15 +34,19 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
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.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import java.math.BigDecimal
import java.math.RoundingMode
import kotlinx.coroutines.launch
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
@@ -55,13 +59,16 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val boostsState by baseNote.liveBoosts.observeAsState()
val boostedNote = boostsState?.note
val zapsState by baseNote.liveZaps.observeAsState()
val zappedNote = zapsState?.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) }
val uri = LocalUriHandler.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
@@ -78,8 +85,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = baseNote }
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
wantsToReplyTo = baseNote
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to reply",
Toast.LENGTH_SHORT
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
@@ -97,8 +115,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.boost(baseNote) }
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
accountViewModel.boost(baseNote)
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to boost posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
Icon(
@@ -125,8 +154,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.reactTo(baseNote) }
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
accountViewModel.reactTo(baseNote)
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to like Posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
Icon(
@@ -152,9 +192,53 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable()) {
accountViewModel.zap(baseNote, 1000 * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
} else {
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
).show()
}
}
}
) {
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
showAmount(zappedNote?.zappedAmount()),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
@@ -178,25 +262,33 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
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 {
if (size == null) return " "
return if (size == 0) return " " else "$size"
fun showCount(count: Int?): String {
if (count == null) return " "
if (count == 0) return " "
return when {
count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
count >= 1000000 -> "${Math.round(count / 1000000f)}M"
count >= 1000 -> "${Math.round(count / 1000f)}k"
else -> "$count"
}
}
val OneGiga = BigDecimal(1000000000)
val OneMega = BigDecimal(1000000)
val OneKilo = BigDecimal(1000)
fun showAmount(amount: BigDecimal?): String {
if (amount == null) return " "
if (amount.abs() < BigDecimal(0.01)) return " "
return when {
amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))
amount >= OneMega -> "%.1fM".format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP))
amount >= OneKilo -> "%.1fk".format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP))
else -> "%.0f".format(amount)
}
}

View File

@@ -49,7 +49,8 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
top = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)

View File

@@ -0,0 +1,123 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
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
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@Composable
fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by account.userProfile().liveFollows.observeAsState()
val userFollows = userState?.user ?: return
val noteState by baseNote.second.live.observeAsState()
val noteZap = noteState?.note ?: return
val baseNoteRequest by baseNote.first.live.observeAsState()
val noteZapRequest = baseNoteRequest?.note ?: return
val baseAuthor = noteZapRequest.author
val ctx = LocalContext.current.applicationContext
if (baseAuthor == null) {
BlankNote()
} else {
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${baseAuthor.pubkeyHex}") }
),
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseAuthor)
}
val userState by baseAuthor.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val amount =
(noteZap.event as? LnZapEvent)?.amount
Column(
modifier = Modifier.padding(start = 10.dp),
verticalArrangement = Arrangement.Center
) {
Text(
showAmount(amount) + " sats",
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.isHidden(baseAuthor)) {
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (userFollows.isFollowing(baseAuthor)) {
UnfollowButton { account.unfollow(baseAuthor) }
} else {
FollowButton { account.follow(baseAuthor) }
}
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}

View File

@@ -0,0 +1,91 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@Composable
fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by zapSetCard.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 == null) {
BlankNote(Modifier, isInnerNote)
} else {
val isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.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,
top = 10.dp)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = Modifier.size(25.dp).align(Alignment.TopEnd)
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}
}
}

View File

@@ -27,6 +27,8 @@ 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.BitmapPainter
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
@@ -34,7 +36,10 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import nostr.postr.toNpub
@@ -43,6 +48,8 @@ import nostr.postr.toNpub
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
var presenting by remember { mutableStateOf(true) }
val ctx = LocalContext.current.applicationContext
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -62,46 +69,57 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
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)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImageProxy(
model = ResizeImage(user.profilePicture(), 100.dp),
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
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)
)
}
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(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Button(
@@ -121,6 +139,15 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
} else {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
"Point to the QR Code",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
@@ -128,18 +155,24 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth().padding(30.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Text(text = "Show QR")
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")
}
}
}

View File

@@ -24,6 +24,14 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
override fun id() = note.idHex + "L" + createdAt
}
class ZapSetCard(val note: Note, val zapEvents: List<Note>): Card() {
val createdAt = zapEvents.maxOf { it.event?.createdAt ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "Z" + createdAt
}
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }

View File

@@ -23,6 +23,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -100,6 +101,13 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,

View File

@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.concurrent.atomic.AtomicBoolean
@@ -68,6 +69,21 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is LnZapEvent}
.forEach { zapEvent ->
val zappedPost = zapEvent.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (zappedPost != null) {
val key = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (key != null) {
zapsPerEvent.getOrPut(zappedPost, { mutableListOf() }).add(key)
}
}
}
val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is RepostEvent }
@@ -79,9 +95,9 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent }.map { NoteCard(it) }
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { NoteCard(it) }
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (reactionCards + boostCards + zapCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -109,7 +125,8 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
if (feedContent.value is CardFeedState.Loaded)
delay(5000)
refresh()
handlerWaiting.set(false)
}
@@ -125,9 +142,6 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
dataSource.stop()
viewModelScope.cancel()
super.onCleared()
}
}

View File

@@ -77,7 +77,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
navController
)
}
FeedState.Loading -> {
is FeedState.Loading -> {
LoadingFeed()
}
}

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
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
@@ -16,19 +14,16 @@ 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.model.RepostEvent
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
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
import java.util.concurrent.atomic.AtomicBoolean
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
@@ -58,14 +53,14 @@ class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSour
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no replies
return dataSource.feed().filter { it.isNewThread() }.take(100)
return dataSource.feed().filter { it.isNewThread() }.take(1000)
}
}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: only replies
return dataSource.feed().filter {! it.isNewThread() }.take(100)
return dataSource.feed().filter {! it.isNewThread() }.take(1000)
}
}
@@ -83,34 +78,39 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
val notes = newListFromDataSource()
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
}
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
fun refreshSuspended() {
val notes = newListFromDataSource()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
private fun updateFeed(notes: List<Note>) {
val currentState = feedContent.value
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
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)) }
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)) }
}
}
}
@@ -122,7 +122,8 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
if (feedContent.value is FeedState.Loaded)
delay(5000)
refresh()
handlerWaiting.set(false)
}
@@ -138,9 +139,6 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
dataSource.stop()
viewModelScope.cancel()
super.onCleared()
}
}

View File

@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
sealed class LnZapFeedState {
object Loading : LnZapFeedState()
class Loaded(val feed: MutableState<List<Pair<Note, Note>>>): LnZapFeedState()
object Empty : LnZapFeedState()
class FeedError(val errorMessage: String) : LnZapFeedState()
}

View File

@@ -0,0 +1,91 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
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
import androidx.compose.runtime.setValue
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.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is LnZapFeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is LnZapFeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is LnZapFeedState.Loaded -> {
LnZapFeedLoaded(state, accountViewModel, navController)
}
is LnZapFeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
@Composable
private fun LnZapFeedLoaded(
state: LnZapFeedState.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.second.idHex }) { index, item ->
ZapNoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
}

View File

@@ -0,0 +1,93 @@
package com.vitorpamplona.amethyst.ui.screen
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.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
class NostrUserProfileZapsFeedViewModel: LnZapFeedViewModel(NostrUserProfileZapsDataSource)
open class LnZapFeedViewModel(val dataSource: NostrDataSource<Pair<Note, Note>>): ViewModel() {
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
private fun updateFeed(notes: List<Pair<Note,Note>>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty }
} else if (currentState is LnZapFeedState.Loaded) {
// updates the current list
currentState.feed.value = notes
} else {
_feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) }
}
}
}
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(1000)
refresh()
handlerWaiting.set(false)
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
invalidateData()
}
init {
LocalCache.live.observeForever(cacheListener)
}
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
super.onCleared()
}
}

View File

@@ -20,6 +20,8 @@ import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.note.RelayCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -59,21 +61,20 @@ class RelayFeedViewModel: ViewModel() {
}
}
inner class CacheListener: User.Listener() {
override fun onNewRelayInfo() { invalidateData() }
override fun onRelayChange() { invalidateData() }
val listener: (UserState) -> Unit = {
invalidateData()
}
val listener = CacheListener()
fun subscribeTo(user: User) {
currentUser = user
user.subscribe(listener)
user.liveRelays.observeForever(listener)
user.liveRelayInfo.observeForever(listener)
invalidateData()
}
fun unsubscribeTo(user: User) {
user.unsubscribe(listener)
user.liveRelays.removeObserver(listener)
user.liveRelayInfo.removeObserver(listener)
currentUser = null
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
sealed class UserFeedState {

View File

@@ -59,7 +59,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
is UserFeedState.Loaded -> {
FeedLoaded(state, accountViewModel, navController)
}
UserFeedState.Loading -> {
is UserFeedState.Loading -> {
LoadingFeed()
}
}

View File

@@ -54,7 +54,8 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
if (notes != oldNotesState.feed) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
updateFeed(notes)
}
} else {
@@ -86,7 +87,8 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
if (feedContent.value is UserFeedState.Loaded)
delay(5000)
refresh()
handlerWaiting.set(false)
}
@@ -102,9 +104,6 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
dataSource.stop()
viewModelScope.cancel()
super.onCleared()
}
}

View File

@@ -1,10 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
@@ -20,6 +24,27 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.reactTo(note)
}
fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) {
val lud16 = note.author?.info?.lud16
if (lud16.isNullOrBlank()) {
onError("User does not have a lightning address setup to receive sats")
return
}
val zapRequest = account.createZapRequestFor(note)
LightningAddressResolver().lnAddressInvoice(lud16, amount, message, zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
},
onError = onError
)
}
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
}

View File

@@ -38,6 +38,7 @@ 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.BitmapPainter
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -55,11 +56,14 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.PostButton
@@ -129,6 +133,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
onPost = {
account.sendChannelMeesage(newPost.value.text, channel.idHex, null, null)
newPost.value = TextFieldValue("")
feedViewModel.refresh() // Don't wait a full second before updating
},
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
@@ -147,15 +152,19 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
@Composable
fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: AccountStateViewModel, navController: NavController) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
val channel = channelState?.channel ?: return
val context = LocalContext.current.applicationContext
Column() {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = channel?.profilePicture(),
placeholder = null,
AsyncImageProxy(
model = ResizeImage(channel.profilePicture(), 35.dp),
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
@@ -168,14 +177,14 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.name}",
"${channel.info.name}",
fontWeight = FontWeight.Bold,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.about}",
"${channel.info.about}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -185,19 +194,18 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
}
Row(modifier = Modifier.height(35.dp).padding(bottom = 3.dp)) {
channel?.let { NoteCopyButton(it) }
NoteCopyButton(channel)
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
}
if (channel.creator == account.userProfile()) {
EditButton(account, channel)
}
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
}
}
}

View File

@@ -74,6 +74,10 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.hardRefresh() // refresh filters
feedViewModel.refresh() // refresh view
}
Column(Modifier.fillMaxHeight()) {
Column(
@@ -88,6 +92,11 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.hardRefresh() // refresh filters
feedViewModel.refresh() // refresh view
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)

View File

@@ -24,10 +24,13 @@ 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.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
@@ -38,8 +41,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -106,6 +112,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId)
newPost.value = TextFieldValue("")
feedViewModel.refresh() // Don't wait a full second before updating
},
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
@@ -124,6 +131,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val ctx = LocalContext.current.applicationContext
Column(modifier = Modifier.clickable(
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
)
@@ -132,11 +141,13 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.liveMetadata.observeAsState()
val author = authorState?.user
val author = authorState?.user!!
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
AsyncImageProxy(
model = ResizeImage(author.profilePicture(), 35.dp),
placeholder = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)

View File

@@ -34,6 +34,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
LaunchedEffect(Unit) {
feedViewModel.refresh()
feedViewModelReplies.refresh()
}
Column(Modifier.fillMaxHeight()) {

View File

@@ -1,14 +1,20 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.*
@@ -25,6 +31,8 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -32,6 +40,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
@@ -41,15 +52,23 @@ 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.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import nostr.postr.toNsec
@@ -62,15 +81,34 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
if (userId == null) return
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
NostrUserProfileZapsDataSource.loadUserProfile(userId)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profile Start")
NostrUserProfileDataSource.start()
NostrUserProfileFollowersDataSource.start()
NostrUserProfileFollowsDataSource.start()
NostrUserProfileZapsDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Profile Stop")
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileZapsDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
@@ -162,6 +200,17 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
val userState by baseUser.liveZaps.observeAsState()
val userZaps = userState?.user?.zappedAmount()
Text(text = "${showAmount(userZaps)} Zaps")
}
)
Tab(
selected = pagerState.currentPage == 4,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } },
text = {
val userState by baseUser.liveRelays.observeAsState()
val userRelaysBeingUsed =
@@ -175,7 +224,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
)
}
HorizontalPager(
count = 4,
count = 5,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
@@ -185,7 +234,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabRelays(baseUser, accountViewModel, navController)
3 -> TabReceivedZaps(baseUser, accountViewModel, navController)
4 -> TabRelays(baseUser, accountViewModel, navController)
}
}
}
@@ -289,7 +339,7 @@ private fun ProfileHeader(
}
}
DrawAdditionalInfo(baseUser)
DrawAdditionalInfo(baseUser, account)
Divider(modifier = Modifier.padding(top = 6.dp))
}
@@ -297,10 +347,12 @@ private fun ProfileHeader(
}
@Composable
private fun DrawAdditionalInfo(baseUser: User) {
private fun DrawAdditionalInfo(baseUser: User, account: Account) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
@@ -308,9 +360,60 @@ private fun DrawAdditionalInfo(baseUser: User) {
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
"@${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
val website = user.info.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = "Website",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { user.info.website?.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
}
var ZapExpanded by remember { mutableStateOf(false) }
val lud16 = user.info.lud16
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = BitcoinOrange,
imageVector = Icons.Default.Bolt,
contentDescription = "Lightning Address",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(lud16),
onClick = { ZapExpanded = !ZapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f)
)
}
if (ZapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16, baseUser.pubkeyHex, account) {
ZapExpanded = false
}
}
}
}
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
@@ -326,8 +429,8 @@ private fun DrawBanner(baseUser: User) {
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
AsyncImageProxy(
model = ResizeImage(banner, 125.dp),
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
@@ -352,6 +455,10 @@ fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavC
if (accountState != null) {
val feedViewModel: NostrUserProfileFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -366,6 +473,10 @@ fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavC
fun TabFollows(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrUserProfileFollowsUserFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -379,6 +490,10 @@ fun TabFollows(user: User, accountViewModel: AccountViewModel, navController: Na
fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrUserProfileFollowersUserFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -388,6 +503,26 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
fun TabReceivedZaps(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileZapsFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
LnZapFeedView(feedViewModel, accountViewModel, navController)
}
}
}
}
@Composable
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: RelayFeedViewModel = viewModel()

View File

@@ -36,19 +36,27 @@ 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.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
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.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -69,11 +77,30 @@ import kotlinx.coroutines.withContext
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")
NostrGlobalDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -95,6 +122,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val onlineSearch = NostrSearchEventOrUserDataSource
val ctx = LocalContext.current.applicationContext
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.isNotBlank()
@@ -210,7 +239,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item ->
ChannelName(
channelPicture = item.profilePicture(),
channelPicturePlaceholder = null,
channelPicturePlaceholder = BitmapPainter(RoboHashCache.get(ctx, item.idHex)),
channelTitle = {
Text(
"${item.info.name}",

View File

@@ -5,22 +5,44 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
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.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.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.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
DisposableEffect(account) {
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")
NostrThreadDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
NostrThreadDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
NostrThreadDataSource.stop()
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
@@ -29,6 +51,10 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
val feedViewModel: NostrThreadFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)

View File

@@ -6,6 +6,7 @@ val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val BitcoinOrange = Color (0xFFF7931A)
val Following = Color(0xFF03DAC5)
val FollowsFollow = Color.Yellow

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB