Compare commits

...
98 Commits
Author SHA1 Message Date
Vitor Pamplona ff927d4830 v0.51.2 2023-05-25 18:38:34 -04:00
Vitor Pamplona 6461c3e498 Changes the structure of the notification view to increase speed. 2023-05-25 18:35:23 -04:00
Vitor Pamplona 2ed5eae029 Preserves invalidations of DataSources for Home, Videos and Notifications 2023-05-25 18:34:40 -04:00
Vitor Pamplona 1693455d0d Only removes scrollTop from Uri after 1 second and to avoid changing before composition finishes. 2023-05-25 18:33:22 -04:00
Vitor Pamplona 3bcda33fa6 Activates invalidations that can be ignored if another invalidation is already running. 2023-05-25 18:30:42 -04:00
Vitor Pamplona 9f394900a7 Only scrolls to the top if not at the top 2023-05-25 18:30:15 -04:00
Vitor Pamplona 4f2c78e73a Remembers formatting for the Notification Reaction Row 2023-05-25 18:29:41 -04:00
Vitor Pamplona 6a3600fd73 Updates Notification lists in intervals of 1 second. 2023-05-25 18:28:51 -04:00
Vitor Pamplona b40c6f1361 remember modifiers to avoid recomposition 2023-05-25 18:28:34 -04:00
Vitor Pamplona 5ce19474ae Moves Home and Video screen invalidations to follow the scrollTop parameter 2023-05-25 18:28:19 -04:00
Vitor Pamplona e9bdc045ba Adds @Stable tag to Notification Card models 2023-05-25 18:27:05 -04:00
Vitor Pamplona aed7f46ca0 remembering size of the robohash picture 2023-05-25 18:25:47 -04:00
Vitor Pamplona 310a93ef29 removes logs 2023-05-25 16:24:16 -04:00
Vitor Pamplona 1ccaa1a028 Removing the start of relays before logging in. 2023-05-25 08:56:04 -04:00
Vitor Pamplona 493888d121 v0.51.1 2023-05-24 18:12:46 -04:00
Vitor Pamplona e5aba62fdd Imports BahaUrlPreview library to add the proxy connection into it. 2023-05-24 18:11:51 -04:00
Vitor PamplonaandGitHub b643cedf8f Merge pull request #424 from KotlinGeekDev/retain-state
Retain state when rotating device.
2023-05-24 17:02:27 -04:00
Vitor Pamplona 2c0bfe2118 v0.51.0 2023-05-24 17:01:30 -04:00
Vitor Pamplona f1ecaf1ee5 - Adds Immutable Lists to avoid recompositions
- Moves NavController to a lambda to avoid recompositions
- Moves AccountViewModel builder to a ViewModel Factory to avoid recompositions
- Reduces notifications invalidation requests when starting the screen.
2023-05-24 12:34:09 -04:00
Vitor Pamplona 4841d4004e Fixes https://github.com/vitorpamplona/amethyst/issues/425 2023-05-24 10:38:25 -04:00
KotlinGeek 75d56214d2 Use manifest to avoid Activity recreation. 2023-05-23 23:58:59 +01:00
Vitor Pamplona f0ba68e467 Fixing missing some notifications. 2023-05-23 16:04:29 -04:00
Vitor Pamplona 5a375b94d6 Adds Zap amounts to Notification bubbles. 2023-05-23 15:14:00 -04:00
Vitor Pamplona dcbd4148e2 v0.50.6 2023-05-23 12:14:47 -04:00
Vitor Pamplona 0015100dea Fixing Immutable imports 2023-05-23 12:12:43 -04:00
Vitor Pamplona 8898e5d10b Adds Immutable and Stable annotations to avoid recomposing 2023-05-23 12:02:34 -04:00
Vitor Pamplona 15621f16a2 Improves the speed of updating Notification notes 2023-05-23 10:37:34 -04:00
Vitor Pamplona a29d9ad433 Adds a cache layer for citations in Notes 2023-05-23 10:37:18 -04:00
Vitor Pamplona d355f22938 Fixes click on the Home to update the feed 2023-05-23 10:35:56 -04:00
Vitor Pamplona 2082c6cb28 Fixes size for following symbol 2023-05-22 21:01:25 -04:00
Vitor Pamplona 4b49cc1aab better translations to unfollow in portuguese 2023-05-22 20:47:34 -04:00
Vitor Pamplona 538e61b38e rollback vm version 2023-05-22 20:47:13 -04:00
Vitor Pamplona 93f8847544 moving plugin to the latest 2023-05-22 20:41:42 -04:00
Vitor Pamplona a1da4affeb Updating dependencies 2023-05-22 20:04:33 -04:00
Vitor Pamplona 0bf94f316f Moving more variables to remember clauses 2023-05-22 20:04:23 -04:00
Vitor Pamplona a3fc22a04a v0.50.5 2023-05-22 18:24:50 -04:00
Vitor Pamplona 6437a2106a Moves zapProgress to inside the IO update to make sure it only updates when needed. 2023-05-22 17:52:46 -04:00
Vitor Pamplona 0a8c79dacd Stops recomposing the drawer at every new route. 2023-05-22 17:52:18 -04:00
Vitor Pamplona 22dbaaf95a Adjusts bottombar to not recalculate hasNewItems in every recomposition. 2023-05-22 17:51:59 -04:00
Vitor Pamplona 278091e6ef Moves default state of url previews to the remember function 2023-05-22 17:51:37 -04:00
Vitor Pamplona d99711d3e6 Remembering Background color and Text elements for posts. 2023-05-22 17:51:12 -04:00
Vitor Pamplona 48f8cd7d95 Merge remote-tracking branch 'origin/HEAD' 2023-05-22 15:11:10 -04:00
Vitor Pamplona 3b816559e4 Optimizes MultiSetCompose 2023-05-22 15:10:54 -04:00
Vitor Pamplona e96a50b454 Optimizes recomposition of bottom items. 2023-05-22 15:08:14 -04:00
Vitor Pamplona 93282b71ae Optimizes NIP-05 procedures 2023-05-22 15:07:47 -04:00
Vitor Pamplona 8223476cb3 Formatting 2023-05-22 15:07:21 -04:00
Vitor Pamplona 460187c4a4 Optimizes Drawer for Recomposition 2023-05-22 15:07:13 -04:00
Vitor Pamplona 5da49c920b Removes the time log for Composing a note 2023-05-22 14:55:06 -04:00
Vitor Pamplona df5b5b90af Formatting 2023-05-22 14:54:42 -04:00
Vitor Pamplona e5b937ed7e Only updates the refreshing state if it was refreshing. 2023-05-22 14:54:25 -04:00
Vitor PamplonaandGitHub b4b90cfd19 Merge pull request #420 from KotlinGeekDev/back-navigation-fix
Improve navigation behaviour.
2023-05-22 14:35:50 -04:00
Vitor Pamplona 99d302802a Deprecating IMGUR 2023-05-22 09:50:06 -04:00
Vitor Pamplona ec2fc2f7e3 Updates relay UI on messages sent by the logged in user in chat 2023-05-22 09:49:34 -04:00
Vitor Pamplona 26634ccb04 Migrates notification summary to a new day without having to restart the app. 2023-05-22 09:18:13 -04:00
KotlinGeek 7766886587 Apply fix to profile navigation as well. 2023-05-22 12:56:17 +01:00
Vitor Pamplona b219b3631c - Adds hashtags and #[n] rendering to markdown
- Shortcuts the link to Amethyst's router.
2023-05-21 20:54:42 -04:00
Vitor Pamplona a0797f7104 Fixes NWC with Auth 2023-05-21 20:53:57 -04:00
Vitor Pamplona 330f3503a6 Adds nostr: prefix to channel id share. 2023-05-21 20:53:20 -04:00
KotlinGeek 7da87af0fb Verify presence of note in backstack before navigating to it. 2023-05-22 01:29:38 +01:00
Vitor Pamplona 2eb0096630 Adds a space in a bookmark author's name 2023-05-20 20:35:13 -04:00
Vitor Pamplona 639569cd86 v0.50.4 2023-05-17 17:05:06 -04:00
Vitor Pamplona e41df98920 Bugfix for channel metadata messages show up before channel creation packages 2023-05-17 17:01:07 -04:00
Vitor Pamplona 323e71c7cb Bugfix for saving Replaceable notes in Bookmarks. 2023-05-17 15:47:20 -04:00
Vitor Pamplona cbe9462f28 v0.50.3 2023-05-17 13:55:30 -04:00
Vitor Pamplona 95febc1c8e Merge remote-tracking branch 'origin/main' 2023-05-17 13:55:17 -04:00
Vitor PamplonaandGitHub 5d8ffd1e2c Merge pull request #415 from KotlinGeekDev/hashtag-search-fix
Fix for case-sensitive hashtag issue.
2023-05-17 13:55:02 -04:00
Vitor PamplonaandGitHub f2616855c2 Merge pull request #414 from ZsZolee/main
Replacing some missing Hungarian translation
2023-05-17 13:54:50 -04:00
Vitor Pamplona 5040350be5 Fixes Auth for NIP-42 sporadic connections. 2023-05-17 13:54:20 -04:00
KotlinGeek d6c8651f0d Add lowercase variant of hashtag when creating event. 2023-05-17 17:26:31 +01:00
ZoltanandGitHub c5176b8c49 Replacing some missing Hungarian translation 2023-05-17 17:27:43 +02:00
Vitor Pamplona d239020895 v0.50.2 2023-05-17 11:20:14 -04:00
Vitor Pamplona 117446688b Fixes jumping chat screens on hidden posts. 2023-05-17 11:20:03 -04:00
Vitor Pamplona a8e650a38d v0.50.1 2023-05-17 09:50:43 -04:00
Vitor Pamplona 95a21cc08c Adds Navigation markers to the top panel and reaction watch 2023-05-17 09:49:31 -04:00
Vitor Pamplona 0823f2e7b5 Additive rendering of statistics 2023-05-17 09:34:21 -04:00
Vitor Pamplona cb42196889 Adds reaction watch. 2023-05-16 22:41:47 -04:00
Vitor Pamplona 6aadf8a883 0.49.4 2023-05-16 19:00:44 -04:00
Vitor Pamplona bb9fa8e144 Adding the missing search element 2023-05-16 18:51:08 -04:00
Vitor Pamplona 5a3cf40402 Adds online search when typing the username in the new Post field. 2023-05-16 18:43:58 -04:00
Vitor Pamplona 1519e70b24 Increasing size of the "More Options" button in the Video Feed. 2023-05-16 18:03:24 -04:00
Vitor Pamplona 135596591a Optimizes the code for chat bubble rendering 2023-05-16 17:53:36 -04:00
Vitor Pamplona 389afcb600 Fixes Robohash dependency in size 2023-05-16 17:53:15 -04:00
Vitor Pamplona d6c2966792 Adds color to relay icons 2023-05-16 17:53:02 -04:00
Vitor Pamplona dd8b208a0d Avoiding creating a lnurl link with just those words 2023-05-16 17:52:39 -04:00
Vitor Pamplona 27182a77a4 v0.49.3 2023-05-16 12:25:16 -04:00
Vitor Pamplona cf5f345d54 Formatting 2023-05-16 12:20:35 -04:00
Vitor Pamplona 3950517743 Improves rendering performance for chats. 2023-05-16 12:20:27 -04:00
Vitor Pamplona 7a14a1ab26 Formatting 2023-05-16 11:30:54 -04:00
Vitor Pamplona 9e93a35ee7 Parsing NIP-19 to improve search results. 2023-05-16 11:30:43 -04:00
Vitor Pamplona 76df70c6f6 Making sure filters return similar orders when two posts are published at the same time. 2023-05-16 11:10:17 -04:00
Vitor Pamplona 1d5ff5bea9 v0.49.2 2023-05-16 10:24:45 -04:00
Vitor Pamplona 1767d4a3a3 Enabling a log to help NIP95 relay developers track what the app is doing with their images. 2023-05-16 10:24:05 -04:00
Vitor Pamplona f2d87b78b1 Fixing emoji's on chat handles 2023-05-16 10:12:31 -04:00
Vitor Pamplona 4bf76d1b79 v0.49.1 2023-05-16 09:47:18 -04:00
Vitor Pamplona 17fbf8c01f BugFix for incorrect caching of NIP95 2023-05-16 09:47:10 -04:00
Vitor Pamplona 332490376b Adjustments to Markdown detection and rendering. 2023-05-16 08:52:59 -04:00
Vitor Pamplona 14e670cd9a Avoid color-filtering the emoji 2023-05-16 08:32:25 -04:00
Vitor Pamplona d29e6b4d0b Better shade for Pins 2023-05-16 08:32:03 -04:00
151 changed files with 3093 additions and 1505 deletions
+16 -7
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 163
versionName "0.49.0"
versionCode 177
versionName "0.51.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -82,7 +82,6 @@ android {
lint {
disable 'MissingTranslation'
}
}
dependencies {
@@ -128,17 +127,18 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.1'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'org.jsoup:jsoup:1.13.1'
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -177,8 +177,17 @@ dependencies {
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
implementation "com.patrykandpatrick.vico:core:${vico_version}"
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -14,7 +13,7 @@ fun TranslatableRichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) = ExpandableRichTextViewer(
content,
canPreview,
@@ -22,5 +21,5 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
navController
nav
)
+1
View File
@@ -35,6 +35,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -251,9 +251,9 @@ object LocalPreferences {
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
) ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NotificationCache {
// TODO: This must be account-based
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long) {
@@ -15,7 +15,6 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null
@@ -46,9 +45,6 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
@@ -43,6 +45,7 @@ val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
@@ -53,7 +56,7 @@ class Account(
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
@@ -217,7 +220,12 @@ class Account(
zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
val wcListener = NostrLnZapPaymentResponseDataSource(
fromServiceHex = nip47.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
authSigningKey = nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!
)
wcListener.start()
LocalCache.consume(event, zappedNote) {
@@ -593,18 +601,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -615,18 +638,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -637,18 +675,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -665,18 +718,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.minus(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -1101,4 +1169,5 @@ class AccountLiveData(private val account: Account) : LiveData<AccountState>(Acc
}
}
@Immutable
class AccountState(val account: Account)
@@ -57,7 +57,7 @@ class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
.toSet()
@@ -50,6 +50,23 @@ fun decodePublicKey(key: String): ByteArray {
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex
if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key).toHexKey()
}
} catch (e: Exception) {
null
}
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
@@ -1,11 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
@@ -553,17 +553,20 @@ object LocalCache {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
@@ -578,16 +581,18 @@ object LocalCache {
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: ChannelMessageEvent, relay: Relay?) {
@@ -763,19 +768,18 @@ object LocalCache {
note.addRelay(relay)
}
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
if (!file.exists()) {
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
val stream = FileOutputStream(file)
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
}
} catch (e: IOException) {
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// Already processed this event.
@@ -835,6 +839,12 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
return listOfNotNull(users[key])
}
return users.values.filter {
(it.anyNameStartsWith(username)) ||
it.pubkeyHex.startsWith(username, true) ||
@@ -843,6 +853,16 @@ object LocalCache {
}
fun findNotesStartingWith(text: String): List<Note> {
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && (notes[key] ?: addressables[key]) != null) {
return listOfNotNull(notes[key] ?: addressables[key])
}
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
@@ -858,6 +878,16 @@ object LocalCache {
}
fun findChannelsStartingWith(text: String): List<Channel> {
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && channels[key] != null) {
return listOfNotNull(channels[key])
}
return channels.values.filter {
it.anyNameStartsWith(text) ||
it.idHex.startsWith(text, true) ||
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
@@ -20,6 +22,7 @@ import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
@@ -28,6 +31,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
}
@Stable
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
@@ -439,4 +443,5 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
}
@Immutable
class NoteState(val note: Note)
@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
@@ -20,6 +22,7 @@ import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
@@ -358,6 +361,7 @@ data class RelayInfo(
data class Chatroom(var roomMessages: Set<Note>)
@Stable
class UserMetadata {
var name: String? = null
var username: String? = null
@@ -417,4 +421,5 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
}
@Immutable
class UserState(val user: User)
@@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
@@ -1,14 +1,18 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrLnZapPaymentResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigningKey: ByteArray
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
@@ -36,4 +40,14 @@ class NostrLnZapPaymentResponseDataSource(
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
Client.send(
event,
relay.url
)
}
}
@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
import nostr.postr.toHex
import fr.acinq.secp256k1.Hex
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private var searchString: String? = null
@@ -19,15 +19,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
}
val hexToWatch = try {
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
decodePublicKey(mySearchString).toHex()
} else if (mySearchString.startsWith("note")) {
mySearchString.bechToBytes().toHex()
} else {
mySearchString
}
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
null
}
@@ -4,7 +4,7 @@ import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
"lnurl.+",
Pattern.CASE_INSENSITIVE
)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
@@ -9,6 +10,7 @@ import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
@Immutable
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class AudioTrackEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeAwardEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeDefinitionEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeProfilesEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
@Immutable
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -19,6 +21,7 @@ open class BaseTextNoteEvent(
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
@@ -61,8 +64,10 @@ open class BaseTextNoteEvent(
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
fun findCitations(): Set<HexKey> {
citedNotesCache?.let { return it }
val citations = mutableSetOf<HexKey>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
@@ -102,6 +107,7 @@ open class BaseTextNoteEvent(
}
}
citedNotesCache = citations
return citations
}
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelCreateEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelHideMessageEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMessageEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMetadataEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMuteUserEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
class ContactListEvent(
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class DeletionEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
@@ -14,6 +15,7 @@ import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@Immutable
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@@ -285,6 +287,7 @@ open class Event(
}
}
@Immutable
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
@Immutable
interface EventInterface {
fun id(): HexKey
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
@Immutable
class FileStorageEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileStorageHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
import nostr.postr.Utils
@Immutable
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class HighlightEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import java.math.BigDecimal
@Immutable
interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -12,6 +13,7 @@ import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
@Immutable
class LnZapPaymentRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
@Immutable
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
@@ -11,6 +12,7 @@ import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@Immutable
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ContactMetaData(
val name: String,
val picture: String,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PeopleListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PinListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@@ -11,6 +12,7 @@ const val VALUE_MINIMUM = "value_minimum"
const val CONSENSUS_THRESHOLD = "consensus_threshold"
const val CLOSED_AT = "closed_at"
@Immutable
class PollNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
@@ -8,6 +9,7 @@ import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
@Immutable
class PrivateDmEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ReactionEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.net.URI
import java.util.Date
@Immutable
class RecommendRelayEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelayAuthEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,13 +1,16 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
@Immutable
class ReportEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class RepostEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -43,6 +45,7 @@ class TextNoteEvent(
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
tags.add(listOf("t", it.lowercase()))
}
extraTags?.forEach {
tags.add(listOf("t", it))
@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
fetch(timeOut)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(document)
urlInfoItem.url = url
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
}
}
return isImage
}
fun cleanUp() {
scope.cancel()
callback = null
}
}
@@ -0,0 +1,6 @@
package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback {
fun onComplete(urlInfo: UrlInfoItem)
fun onFailed(throwable: Throwable)
}
@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service.previews
data class UrlInfoItem(
var url: String = "",
var title: String = "",
var description: String = "",
var image: String = ""
) {
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
}
@@ -0,0 +1,116 @@
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/* for <meta property="og:" to get title */
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
/* for <meta property="og:" to get description */
private val META_OG_DESCRIPTION =
arrayOf("og:description", "\"og:description\"", "'og:description'")
/* for <meta property="og:" to get image */
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
/*for <meta name=... to get title */
private val META_NAME_TITLE = arrayOf(
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'"
)
/*for <meta name=... to get description */
private val META_NAME_DESCRIPTION = arrayOf(
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'"
)
/*for <meta name=... to get image */
private val META_NAME_IMAGE = arrayOf(
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
/*for <meta itemprop=... to get title */
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
/*for <meta itemprop=... to get description */
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
/*for <meta itemprop=... to get image */
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
return@withContext Jsoup.connect(url)
.proxy(HttpClient.getProxy())
.timeout(timeOut)
.get()
}
suspend fun parseHtml(document: Document): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
val urlInfo = UrlInfoItem()
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (urlInfo.title.isEmpty()) {
urlInfo.title =
it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(
CONTENT
)
}
in META_ITEMPROP_IMAGE -> if (urlInfo.image.isEmpty()) {
urlInfo.image = it.attr(
CONTENT
)
}
}
if (urlInfo.allFetchComplete()) {
return@withContext urlInfo
}
}
return@withContext urlInfo
}
@@ -70,7 +70,7 @@ object Client : RelayPool.Listener {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
@@ -103,7 +103,7 @@ object Client : RelayPool.Listener {
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
@@ -30,6 +30,7 @@ class Relay(
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
@@ -30,6 +30,10 @@ object RelayPool : Relay.Listener {
return relays.firstOrNull() { it.url == url }
}
fun getRelays(url: String): List<Relay> {
return relays.filter { it.url == url }
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }
@@ -20,6 +20,7 @@ import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -46,35 +47,7 @@ class MainActivity : FragmentActivity() {
val uri = intent?.data?.toString()
val startingPage = if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
} ?: try {
uri?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
val startingPage = uriToRoute(uri)
// Initializes video cache.
VideoCache.init(this.applicationContext)
@@ -88,6 +61,7 @@ class MainActivity : FragmentActivity() {
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
@@ -150,3 +124,39 @@ class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
}
}
}
fun uriToRoute(uri: String?): String? {
return if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
} ?: try {
uri?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
}
}
@@ -40,9 +40,9 @@ object ImageUploader {
"Can't open the image input stream"
}
val myServer = when (server) {
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
ImgurServer()
}
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
@@ -53,7 +53,7 @@ object ImageUploader {
NostrFilesDevServer()
}
else -> {
ImgurServer()
NostrBuildServer()
}
}
@@ -51,7 +51,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -73,8 +72,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = account
Dialog(
onDismissRequest = {
@@ -115,7 +115,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, account, navController)
RenderSeach(searchBarViewModel, account, nav)
}
}
}
@@ -126,7 +126,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
private fun RenderSeach(
searchBarViewModel: SearchBarViewModel,
account: Account,
navController: NavController
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -274,7 +274,7 @@ private fun RenderSeach(
UserComposeForChat(
item,
account = account,
navController = navController
nav = nav
)
}
@@ -294,7 +294,7 @@ private fun RenderSeach(
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") }
onClick = { nav("Channel/${item.idHex}") }
)
}
}
@@ -306,12 +306,12 @@ private fun RenderSeach(
fun UserComposeForChat(
baseUser: User,
account: Account,
navController: NavController
nav: (String) -> Unit
) {
Column(
modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
onClick = { nav("Room/${baseUser.pubkeyHex}") }
)
) {
Row(
@@ -323,7 +323,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
UserPicture(baseUser, nav, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -38,9 +38,10 @@ open class NewMediaModel : ViewModel() {
this.mediaType = contentType
this.selectedServer = defaultServer()
if (selectedServer == ServersAvailable.IMGUR) {
selectedServer = ServersAvailable.IMGUR_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
// if (selectedServer == ServersAvailable.IMGUR) {
// selectedServer = ServersAvailable.IMGUR_NIP_94
// } else
if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -39,7 +38,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
@@ -111,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@@ -121,7 +120,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -56,12 +57,12 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
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.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -73,7 +74,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
@@ -95,6 +96,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -284,7 +291,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
true,
MaterialTheme.colors.background,
accountViewModel,
navController
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
@@ -633,11 +640,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
enum class ServersAvailable {
IMGUR,
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
IMGUR_NIP_94,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
@@ -660,11 +668,11 @@ fun ImageVideoDescription(
val isVideo = mediaType.startsWith("video")
val fileServers = listOf(
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
@@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -188,6 +189,8 @@ open class NewPostViewModel : ViewModel() {
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? {
@@ -211,8 +214,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
@@ -225,8 +230,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
@@ -15,14 +15,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
@Composable
fun ChannelFabColumn(account: Account, navController: NavController) {
fun ChannelFabColumn(account: Account, nav: (String) -> Unit) {
var isOpen by remember {
mutableStateOf(false)
}
@@ -40,7 +39,7 @@ fun ChannelFabColumn(account: Account, navController: NavController) {
}
if (wantsToJoinChannelOrUser) {
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, nav = nav)
}
Column() {
@@ -16,20 +16,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(
@@ -22,9 +22,11 @@ class BundledUpdate(
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
fun invalidate() {
fun invalidate(ignoreIfDoing: Boolean = false) {
if (onlyOneInBlock.getAndSet(true)) {
invalidatesAgain = true
if (!ignoreIfDoing) {
invalidatesAgain = true
}
return
}
@@ -5,18 +5,17 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
baseNote: Note,
navController: NavController
nav: (String) -> Unit
) {
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baseNote.idHex}") },
onClick = { nav("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
@@ -38,7 +37,6 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -53,16 +51,16 @@ import kotlinx.coroutines.withContext
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
if (nip19.type == Nip19.Type.USER) {
DisplayUser(nip19, navController)
DisplayUser(nip19, nav)
} else if (nip19.type == Nip19.Type.ADDRESS) {
DisplayAddress(nip19, navController)
DisplayAddress(nip19, nav)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, navController)
DisplayNote(nip19, nav)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, navController)
DisplayEvent(nip19, nav)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
@@ -73,7 +71,7 @@ fun ClickableRoute(
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
@@ -85,36 +83,36 @@ private fun DisplayEvent(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
val note = remember(noteState) { noteState?.note } ?: return
val channel = remember(noteState) { note.channel() }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Channel/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
"${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}",
navController
clickablePart = channel.toBestDisplayName(),
suffix = "${nip19.additionalChars} ",
route = "Channel/${channel.idHex}",
nav = nav
)
} else {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Event/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
nav = nav
)
}
}
@@ -129,7 +127,7 @@ private fun DisplayEvent(
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
@@ -146,31 +144,31 @@ private fun DisplayNote(
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Channel/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
"${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}",
navController
clickablePart = channel.toBestDisplayName(),
suffix = "${nip19.additionalChars} ",
route = "Channel/${note.channel()?.idHex}",
nav = nav
)
} else {
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
nav = nav
)
}
}
@@ -185,7 +183,7 @@ private fun DisplayNote(
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
@@ -197,13 +195,13 @@ private fun DisplayAddress(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val note = remember(noteState) { noteState?.note } ?: return
CreateClickableText(
note.idDisplayNote(),
"${nip19.additionalChars} ",
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
nav = nav
)
}
@@ -217,7 +215,7 @@ private fun DisplayAddress(
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(null) }
@@ -239,7 +237,7 @@ private fun DisplayUser(
suffix = "${nip19.additionalChars} ",
tags = userTags,
route = route,
navController = navController
nav = nav
)
}
}
@@ -252,21 +250,28 @@ private fun DisplayUser(
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
fun CreateClickableText(
clickablePart: String,
suffix: String,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
nav: (String) -> Unit
) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
) {
append("@$clickablePart")
append(clickablePart)
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()
) {
append(suffix)
}
},
onClick = { navController.navigate(route) }
onClick = { nav(route) }
)
}
@@ -369,30 +374,31 @@ fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: List<List<String>>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
navController: NavController
nav: (String) -> Unit
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
CreateClickableText(clickablePart, suffix, route, navController)
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
navController.navigate(route)
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
nav(route)
}
val myList2 = remember {
assembleAnnotatedList(suffix, emojis)
}
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
}
}
@@ -427,8 +433,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
colorFilter = ColorFilter.tint(style.color)
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
@@ -492,8 +497,7 @@ fun InLineIconRenderer(
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
colorFilter = ColorFilter.tint(style.color)
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
@@ -7,18 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
@Composable
fun ClickableUserTag(
user: User,
navController: NavController
nav: (String) -> Unit
) {
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -25,7 +25,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -39,7 +38,7 @@ fun ExpandableRichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
var showFullText by remember { mutableStateOf(false) }
@@ -69,7 +68,7 @@ fun ExpandableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
navController
nav
)
if (content.length > whereToCut && !showFullText) {
@@ -19,14 +19,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -81,7 +81,8 @@ fun isMarkdown(content: String): Boolean {
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
content.contains("```") ||
content.contains("](")
}
@Composable
@@ -92,19 +93,20 @@ fun RichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
val isMarkdown = remember { isMarkdown(content) }
Column(modifier = modifier) {
if (isMarkdown) {
RenderContentAsMarkdown(content, backgroundColor)
RenderContentAsMarkdown(content, backgroundColor, tags, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
}
}
}
@Stable
class RichTextViewerState(
val content: String,
val urlSet: Set<String>,
@@ -120,10 +122,10 @@ private fun RenderRegular(
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
var state by remember {
mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
val scope = rememberCoroutineScope()
@@ -147,80 +149,73 @@ private fun RenderRegular(
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
state = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
}
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
processedState?.let { state ->
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
@@ -228,71 +223,76 @@ private fun RenderRegular(
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@@ -309,7 +309,7 @@ fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List<List<String>>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
@@ -333,7 +333,6 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
@@ -351,14 +350,14 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
nip19References = returnNIP19References(content, tags)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
}
}
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
@@ -388,17 +387,23 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
refresh++
LaunchedEffect(key1 = noteState) {
refresh++
}
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
refresh++
LaunchedEffect(key1 = userState) {
refresh++
}
}
}
}
val uri = LocalUriHandler.current
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
@@ -406,26 +411,70 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
)
) { link ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
}
}
}
}
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.EVENT) {
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else {
return null
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $tag", e)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
}
}
return null
}
private fun returnNIP19References(content: String): List<Nip19.Return> {
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
if (nip19.type == Nip19.Type.USER) {
LocalCache.users[nip19.hex]?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (nip19.type == Nip19.Type.NOTE) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.ADDRESS) {
LocalCache.addressables[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.EVENT) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
return null
}
private fun returnNIP19References(content: String, tags: List<List<String>>?): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -437,10 +486,21 @@ private fun returnNIP19References(content: String): List<Nip19.Return> {
}
}
}
tags?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
} else if (it[0] == "a" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
}
}
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String): String {
private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<String>>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -453,15 +513,43 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
val pair = getDisplayNameFromNip19(parsedNip19)
if (pair != null) {
val (displayName, nip19) = pair
"[$displayName](nostr:$nip19) "
} else {
"$word "
}
} else {
"$word "
}
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
val pair = getDisplayNameAndNIP19FromTag(word, tags)
if (pair != null) {
returnContent += "[${pair.first}](nostr:${pair.second}) "
} else {
returnContent += "$word "
}
} else if (hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
@@ -482,12 +570,14 @@ fun isBechLink(word: String): Boolean {
}
@Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
@@ -516,7 +606,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
nav = nav
)
if (!it.second.isNullOrEmpty()) {
Text(
@@ -524,21 +614,23 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
)
}
} ?: nip19Route?.let {
ClickableRoute(it, navController)
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
} else {
nip19Route?.let {
ClickableRoute(it, navController)
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
}
}
@Composable
fun HashTag(word: String, navController: NavController) {
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
@@ -556,7 +648,7 @@ fun HashTag(word: String, navController: NavController) {
}
tagSuffixPair?.let { tagPair ->
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) }
ClickableText(
text = buildAnnotatedString {
withStyle(
@@ -565,7 +657,7 @@ fun HashTag(word: String, navController: NavController) {
append("#${tagPair.first}")
}
},
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
onClick = { nav("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
@@ -611,7 +703,7 @@ fun HashTag(word: String, navController: NavController) {
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
@@ -663,7 +755,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
suffix = "${it.second} ",
tags = userTags,
route = route,
navController = navController
nav = nav
)
}
@@ -683,13 +775,13 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
nav = nav
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
}
} else {
ClickableNoteTag(it.first, navController)
ClickableNoteTag(it.first, nav)
Text(text = "${it.second} ")
}
}
@@ -34,10 +34,12 @@ fun RobohashAsyncImage(
) {
val context = LocalContext.current
val size = with(LocalDensity.current) {
robotSize.roundToPx()
remember {
robotSize.roundToPx()
}
}
val imageRequest = remember(size, robot) {
val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest(
context,
robot,
@@ -132,6 +134,7 @@ fun RobohashAsyncImageProxy(
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val proxy = remember(model) { model.proxyUrl() }
if (proxy == null) {
RobohashAsyncImage(
robot = robot,
@@ -10,25 +10,28 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
val context = LocalContext.current
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
var urlPreviewState by remember {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
mutableStateOf(default)
}
val scope = rememberCoroutineScope()
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
@@ -20,8 +20,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import java.net.URL
@Composable
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
sealed class UrlPreviewState {
object Loading : UrlPreviewState()
@@ -19,7 +19,7 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -14,7 +14,7 @@ object BookmarkPublicFeedFilter : FeedFilter<Note>() {
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -21,7 +21,7 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
return channel.notes
.values
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@@ -35,6 +35,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -28,7 +28,7 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
return messages.roomMessages
.filter { myAccount.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@@ -50,6 +50,6 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -19,20 +19,20 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
val privateMessages = messagingWith.mapNotNull { it ->
privateChatrooms[it]
?.roomMessages
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map { it ->
it.notes.values
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels)
.filterNotNull()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -19,12 +19,12 @@ object ChatroomListNewFeedFilter : FeedFilter<Note>() {
val privateMessages = messagingWith.mapNotNull { it ->
privateChatrooms[it]
?.roomMessages
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.lastOrNull { it.event != null }
}
return privateMessages
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -44,6 +44,6 @@ object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -34,6 +34,6 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -41,6 +41,6 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -41,7 +41,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
fun tagsAnEventByUser(note: Note, author: User): Boolean {
@@ -19,7 +19,7 @@ object PeopleListFeedFilter : FeedFilter<Note>() {
.toSet()
return lists
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -25,7 +25,7 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
return (notes + addresses)
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -16,7 +16,7 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
return user?.notes
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
}
}
@@ -23,7 +23,7 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
return user?.notes
?.plus(longFormNotes)
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
}
}
@@ -14,7 +14,7 @@ object UserProfileReportsFeedFilter : FeedFilter<Note>() {
return user?.reports
?.values
?.flatten()
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
}
}
@@ -5,6 +5,7 @@ import android.view.ViewTreeObserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -33,9 +34,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -82,11 +85,7 @@ fun keyboardAsState(): State<Keyboard> {
@Composable
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
val currentRoute = currentRoute(navController)
val currentRouteBase = currentRoute?.substringBefore("?")
val coroutineScope = rememberCoroutineScope()
val isKeyboardOpen by keyboardAsState()
if (isKeyboardOpen == Keyboard.Closed) {
Column() {
Divider(
@@ -98,37 +97,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
val selected = currentRouteBase == item.base
BottomNavigationItem(
icon = { NotifiableIcon(item, selected, accountViewModel) },
selected = selected,
onClick = {
coroutineScope.launch {
if (currentRouteBase != item.base) {
navController.navigate(item.base) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
val route = currentRoute.replace("{scrollToTop}", "true")
navController.navigate(route) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
}
)
HasNewItemsIcon(item, accountViewModel, navController)
}
}
}
@@ -136,38 +105,117 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
}
@Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
private fun RowScope.HasNewItemsIcon(
route: Route,
accountViewModel: AccountViewModel,
navController: NavHostController
) {
val scope = rememberCoroutineScope()
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = route.icon),
contentDescription = null,
modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp),
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val notifState by NotificationCache.live.observeAsState()
val notif = remember(notifState) { notifState?.cache } ?: return
val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notif) {
scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
LaunchedEffect(key1 = notifState, key2 = accountState) {
scope.launch(Dispatchers.IO) {
val newHasNewItems = route.hasNewItems(account, notif, emptySet())
if (newHasNewItems != hasNewItems) {
hasNewItems = newHasNewItems
}
}
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
hasNewItems = route.hasNewItems(account, notif.cache, it)
LaunchedEffect(accountState) {
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
val newHasNewItems = route.hasNewItems(account, notif, it)
if (newHasNewItems != hasNewItems) {
hasNewItems = newHasNewItems
}
}
}
}
BottomIcon(
icon = route.icon,
size = if ("Home" == route.base) 25.dp else 23.dp,
iconSize = if ("Home" == route.base) 24.dp else 20.dp,
base = route.base,
hasNewItems = hasNewItems,
navController
) { selected ->
scope.launch {
if (!selected) {
navController.navigate(route.base) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = route.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
}
}
@Composable
private fun RowScope.BottomIcon(
icon: Int,
size: Dp,
iconSize: Dp,
base: String,
hasNewItems: Boolean,
navController: NavHostController,
onClick: (Boolean) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
navBackStackEntry?.let {
val selected = remember(it) {
it.destination.route?.substringBefore("?") == base
}
BottomNavigationItem(
icon = {
NotifiableIcon(
icon,
size,
iconSize,
selected,
hasNewItems
)
},
selected = selected,
onClick = { onClick(selected) }
)
}
}
@Composable
private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) {
Box(Modifier.size(size)) {
Icon(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
)
if (hasNewItems) {
Box(
@@ -8,6 +8,7 @@ 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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -18,6 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -37,6 +39,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -50,7 +55,8 @@ fun AppNavigation(
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex }
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
@@ -67,6 +73,24 @@ fun AppNavigation(
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
val userReactionsStatsModel: UserReactionsViewModel = viewModel()
val scope = rememberCoroutineScope()
LaunchedEffect(accountHex) {
scope.launch(Dispatchers.IO) {
userReactionsStatsModel.load(account.userProfile())
userReactionsStatsModel.initializeSuspend()
}
}
val nav = remember {
{ route: String ->
if (getRouteWithArguments(navController) != route) {
navController.navigate(route)
}
}
}
NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
@@ -75,13 +99,19 @@ fun AppNavigation(
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
navController = navController,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
@@ -93,13 +123,19 @@ fun AppNavigation(
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
navController = navController,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
@@ -113,7 +149,7 @@ fun AppNavigation(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
accountViewModel = accountViewModel,
navController = navController,
nav = nav,
pagerState = homePagerState,
scrollToTop = scrollToTop,
nip47 = nip47
@@ -121,10 +157,20 @@ fun AppNavigation(
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
if (nip47 != null) {
it.arguments?.remove("nip47")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("nip47")
}
}
}
})
}
@@ -135,28 +181,35 @@ fun AppNavigation(
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
navController = navController,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) })
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) })
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, nav) })
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
Route.Profile.let { route ->
composable(route.route, route.arguments, content = {
ProfileScreen(
userId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
nav = nav
)
})
}
@@ -166,7 +219,7 @@ fun AppNavigation(
ThreadScreen(
noteId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
nav = nav
)
})
}
@@ -176,7 +229,7 @@ fun AppNavigation(
HashtagScreen(
tag = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
nav = nav
)
})
}
@@ -186,7 +239,7 @@ fun AppNavigation(
ChatroomScreen(
userId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
nav = nav
)
})
}
@@ -196,7 +249,7 @@ fun AppNavigation(
ChannelScreen(
channelId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
nav = nav
)
})
}
@@ -214,7 +267,7 @@ fun AppNavigation(
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it)
nav(it)
}
actionableNextPage = null
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,6 +24,7 @@ import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -81,7 +83,7 @@ import kotlinx.coroutines.withContext
@Composable
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController)
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
@@ -337,7 +339,16 @@ fun SimpleTextSpinner(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(placeholder)
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Text(placeholder)
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Box(
modifier = Modifier
.matchParentSize()

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