Compare commits

...

75 Commits

Author SHA1 Message Date
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 Pamplona
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 Pamplona
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
Zoltan
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
Vitor Pamplona
3558bab786 v0.49.0 2023-05-15 22:18:21 -04:00
Vitor Pamplona
7c21f077db Support for PinStr (33888) 2023-05-15 22:18:12 -04:00
Vitor Pamplona
a2a9d04e1e v0.48.0 2023-05-15 21:35:55 -04:00
Vitor Pamplona
fc56f3988e Support for NIP-30 2023-05-15 21:26:59 -04:00
Vitor Pamplona
95ad33ace2 Merge pull request #412 from Pextar/main
Swedish translation
2023-05-15 14:11:11 -04:00
Vitor Pamplona
cf850415b8 Merge branch 'main' into main 2023-05-15 14:11:06 -04:00
Vitor Pamplona
bd77bb7780 Merge remote-tracking branch 'origin/HEAD' 2023-05-15 14:09:56 -04:00
Vitor Pamplona
6a696bfa39 Adds a 5 minute expiration time for ImageErrors 2023-05-15 14:09:43 -04:00
Pextar
d5fc97184c Merge branch 'main' into main 2023-05-15 20:07:43 +02:00
Pextar
e0b9120e17 Create values-sv 2023-05-15 20:06:08 +02:00
Vitor Pamplona
1523a29a4f Merge pull request #410 from greenart7c3/main
add longPress to edit tor/orbot settings
2023-05-15 13:51:25 -04:00
Vitor Pamplona
1db6b4793f Adds NIP-94 shareable link to filestr 2023-05-15 13:25:21 -04:00
Vitor Pamplona
426a495eb4 Adds zapstr.live and listr.lol as shareable links 2023-05-15 13:17:31 -04:00
Vitor Pamplona
e07b4d49ef Fixes NPE on opening the QuickAction on a "Post Not Found" event. 2023-05-15 13:10:33 -04:00
Vitor Pamplona
595cab3630 v0.47.0 2023-05-15 11:16:50 -04:00
Vitor Pamplona
817a9c3e8d Adds support for AudioTrack Types from (https://zapstr.live/) 2023-05-15 11:15:32 -04:00
greenart7c3
32927040e3 add longPress to edit tor/orbot settings 2023-05-15 11:17:42 -03:00
Vitor Pamplona
c973d941cc v0.46.6 2023-05-14 22:23:37 -04:00
Vitor Pamplona
db6ebe609c Forces aspect ratio while obeying maxWidth and Height of the frame. 2023-05-14 22:22:53 -04:00
Vitor Pamplona
32913915ce Making sure remember does not interfere with translations. 2023-05-14 22:22:22 -04:00
Vitor Pamplona
4a8680e582 0.46.5 2023-05-14 21:06:06 -04:00
Vitor Pamplona
7cf0291b5a Applies remember functions to more parameters. 2023-05-14 21:05:58 -04:00
Vitor Pamplona
0406632e9d Moves internal content changes to derived states. 2023-05-14 20:24:41 -04:00
Vitor Pamplona
bf2949717c Makes sure to only change the visible status when needed. 2023-05-14 20:24:11 -04:00
Vitor Pamplona
bbea28c6df 0.46.4 2023-05-14 19:04:34 -04:00
Vitor Pamplona
1c76b7a35c Testing a not cropping version for videos. 2023-05-14 18:59:49 -04:00
Vitor Pamplona
814d05812a Fixes image cropping opening the image dialog. 2023-05-14 18:59:33 -04:00
Vitor Pamplona
b4d3cf87c8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:21:43 -04:00
Vitor Pamplona
e5ff92c804 v0.46.3 2023-05-14 18:21:33 -04:00
Vitor Pamplona
c3a31fd313 Update PRIVACY.md
Adds the collection of the push notification token to the Privacy.MD
2023-05-14 18:20:28 -04:00
Vitor Pamplona
3fd4c176e8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:12:16 -04:00
Vitor Pamplona
cf9395d5e3 Avoids Notification duplicates 2023-05-14 18:12:04 -04:00
Vitor Pamplona
89ae229ea2 Merge pull request #408 from vivganes/intl-hashtags
fix #407 correct non-english hashtags
2023-05-14 17:37:18 -04:00
vivganes
d261003217 fix #407 correct non-english hashtags 2023-05-14 23:39:42 +05:30
Pextar
fc1ee54548 Merge branch 'vitorpamplona:main' into main 2023-05-14 08:29:09 +02:00
Pextar
754ac9894b Delete values-sv 2023-05-12 20:38:08 +02:00
Pextar
e80c8b37b3 Create values-sv 2023-05-12 12:14:20 +02:00
84 changed files with 2824 additions and 644 deletions

View File

@@ -4,7 +4,7 @@ Effective as of Jan 20, 2023 for the distributed applications in the Play Store
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.

View File

@@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
@@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Lightning Tips
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Created_at Limits (NIP-22)
- [x] Event Deletion (NIP-09)
- [x] Identity Verification (NIP-05)
- [x] Long-form Content (NIP-23)
- [x] Parameterized Replaceable Events (NIP-33)
@@ -54,13 +55,18 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
@@ -69,8 +75,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Account Creation / Backup Guidance
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] OpenTimestamps Attestations (NIP-03)
# Development Overview

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 156
versionName "0.46.2"
versionCode 172
versionName "0.50.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -177,6 +177,12 @@ 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}"
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'

View File

@@ -593,18 +593,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 +630,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 +667,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 +710,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)

View File

@@ -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()

View File

@@ -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? {

View File

@@ -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
@@ -281,6 +281,30 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: PinListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
private fun consume(event: AudioTrackEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -529,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)
}
}
@@ -554,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?) {
@@ -739,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.
@@ -811,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) ||
@@ -819,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) ||
@@ -834,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) ||
@@ -920,6 +974,7 @@ object LocalCache {
try {
when (event) {
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
@@ -948,6 +1003,7 @@ object LocalCache {
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)

View File

@@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.service
import java.util.regex.Pattern
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}

View File

@@ -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
)
)

View File

@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -13,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
limit = 200
)
)

View File

@@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
@@ -56,7 +58,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -72,7 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())

View File

@@ -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
}

View File

@@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -80,7 +80,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -119,7 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -28,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

View File

@@ -4,7 +4,7 @@ import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
"lnurl.+",
Pattern.CASE_INSENSITIVE
)

View File

@@ -0,0 +1,61 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class AudioTrackEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
companion object {
const val kind = 31337
private const val TYPE = "c"
private const val PRICE = "price"
private const val COVER = "cover"
private const val SUBJECT = "subject"
private const val MEDIA = "media"
fun create(
type: String,
media: String,
price: String? = null,
cover: String? = null,
subject: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): AudioTrackEvent {
val tags = listOfNotNull(
listOf(MEDIA, media),
listOf(TYPE, type),
price?.let { listOf(PRICE, it) },
cover?.let { listOf(COVER, it) },
subject?.let { listOf(SUBJECT, it) }
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Participant(val key: String, val role: String?)

View File

@@ -9,9 +9,9 @@ class BadgeDefinitionEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)

View File

@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
@@ -18,8 +18,8 @@ class BadgeProfilesEvent(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 30008

View File

@@ -220,6 +220,7 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
@@ -243,6 +244,7 @@ open class Event(
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
@@ -282,3 +284,8 @@ open class Event(
}
}
}
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
}

View File

@@ -14,9 +14,9 @@ abstract class GeneralListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()

View File

@@ -71,7 +71,7 @@ class LnZapRequestEvent(
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
if (pollOption != null && pollOption >= 0) {

View File

@@ -12,9 +12,9 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()

View File

@@ -15,9 +15,9 @@ class MuteListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? {
return try {

View File

@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class PinListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
companion object {
const val kind = 33888
fun create(
pins: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PinListEvent {
val tags = mutableListOf<List<String>>()
pins.forEach {
tags.add(listOf("pin", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}

View File

@@ -32,7 +32,7 @@ class ReactionEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}

View File

@@ -63,7 +63,7 @@ class ReportEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
if (reportedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
}

View File

@@ -36,7 +36,7 @@ class RepostEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) {
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}

View File

@@ -43,6 +43,7 @@ class TextNoteEvent(
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
tags.add(listOf("t", it.lowercase()))
}
extraTags?.forEach {
tags.add(listOf("t", it))

View File

@@ -46,7 +46,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext)
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
@@ -88,7 +88,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext)
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}
}

View File

@@ -15,10 +15,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils {
// Notification ID.
private var notificationId = 0
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
@@ -63,6 +59,7 @@ object NotificationUtils {
}
fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
@@ -72,10 +69,11 @@ object NotificationUtils {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendDMNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
@@ -85,10 +83,11 @@ object NotificationUtils {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
@@ -104,6 +103,7 @@ object NotificationUtils {
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable,
@@ -113,6 +113,7 @@ object NotificationUtils {
)
} else {
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = null,
@@ -124,6 +125,7 @@ object NotificationUtils {
}
private fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
picture: BitmapDrawable?,
@@ -137,7 +139,7 @@ object NotificationUtils {
val contentPendingIntent = PendingIntent.getActivity(
applicationContext,
notificationId,
id.hashCode(),
contentIntent,
PendingIntent.FLAG_MUTABLE
)
@@ -171,9 +173,7 @@ object NotificationUtils {
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(notificationId, builder.build())
notificationId++
notify(id.hashCode(), builder.build())
}
/**

View File

@@ -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)

View File

@@ -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) }

View File

@@ -75,6 +75,7 @@ import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = account
Dialog(
onDismissRequest = {

View File

@@ -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
@@ -62,6 +63,7 @@ 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
@@ -95,6 +97,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -380,8 +388,9 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
onClick(myUser)
}
) {
Text(
"${myUser.toBestDisplayName()}",
CreateTextWithEmoji(
text = "${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
color = Color.White,
textAlign = TextAlign.Center
)

View File

@@ -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()
}
}

View File

@@ -1,6 +1,14 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -11,12 +19,30 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -63,31 +89,31 @@ private fun DisplayEvent(
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
navController = navController
)
} 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}",
navController = navController
)
} 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}",
navController = navController
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Event/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
navController = navController
)
}
}
@@ -114,36 +140,36 @@ private fun DisplayNote(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val note = remember(noteState) { noteState?.note } ?: return
val channel = 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}",
navController = navController
)
} 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}",
navController = navController
)
} 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}",
navController = navController
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
navController = navController
)
}
}
@@ -173,10 +199,10 @@ private fun DisplayAddress(
val note = noteState?.note ?: return
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
navController = navController
)
}
@@ -202,14 +228,19 @@ private fun DisplayUser(
userBase?.let {
val userState by it.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = remember { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
CreateClickableText(
user.toBestDisplayName(),
nip19.additionalChars,
"User/${nip19.hex}",
navController
)
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "${nip19.additionalChars} ",
tags = userTags,
route = route,
navController = navController
)
}
}
if (userBase == null) {
@@ -220,20 +251,281 @@ 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,
navController: NavController
) {
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 ")
append(suffix)
}
},
onClick = { navController.navigate(route) }
)
}
@Composable
fun CreateTextWithEmoji(
text: String,
tags: List<List<String>>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val emojis = remember {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
}
@Composable
fun CreateTextWithEmoji(
text: String,
emojis: Map<String, String>,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val textColor = color.takeOrElse {
LocalTextStyle.current.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
}
}
if (emojis.isEmpty()) {
Text(
text = text,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
} else {
val myList = remember {
assembleAnnotatedList(text, emojis)
}
val style = LocalTextStyle.current.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize
)
).toSpanStyle()
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: List<List<String>>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
ClickableText(
AnnotatedString(clickablePart),
style = style,
onClick = onClick
)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
onClick(it)
}
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: List<List<String>>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
navController: NavController
) {
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, overrideColor, fontWeight, route, navController)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
navController.navigate(route)
}
val myList2 = remember {
assembleAnnotatedList(suffix, emojis)
}
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
}
}
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
return NIP30Parser().buildArray(text).map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)
} else {
TextType(it)
}
}
}
open class Renderable()
class TextType(val text: String) : Renderable()
class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent,
onTextLayout = {
layoutResult.value = it
}
)
}
@Composable
fun InLineIconRenderer(
wordsInOrder: List<Renderable>,
style: SpanStyle,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
Text(
text = annotatedText,
inlineContent = inlineContent,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,10 +51,14 @@ fun ExpandableRichTextViewer(
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
}
val text = if (showFullText) {
content
} else {
content.take(whereToCut)
val text by remember(content) {
derivedStateOf {
if (showFullText) {
content
} else {
content.take(whereToCut)
}
}
}
Box {

View File

@@ -50,7 +50,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -82,7 +81,8 @@ fun isMarkdown(content: String): Boolean {
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
content.contains("```") ||
content.contains("](")
}
@Composable
@@ -110,10 +110,10 @@ class RichTextViewerState(
val content: String,
val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
val imageList: List<ZoomableUrlContent>,
val customEmoji: Map<String, String>
)
@OptIn(ExperimentalTime::class)
@Composable
private fun RenderRegular(
content: String,
@@ -124,7 +124,7 @@ private fun RenderRegular(
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
val scope = rememberCoroutineScope()
@@ -145,8 +145,10 @@ private fun RenderRegular(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
if (urlSet.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
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)
}
}
}
@@ -169,6 +171,8 @@ private fun RenderRegular(
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)) {
@@ -237,6 +241,8 @@ private fun RenderRegular(
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) {
@@ -295,6 +301,14 @@ private fun RenderRegular(
}
}
@Composable
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
CreateTextWithEmoji(
text = "$word ",
emojis = customEmoji
)
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
val myMarkDownStyle = richTextDefaults.copy(
@@ -400,7 +414,7 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
return LocalCache.users[nip19.hex]?.toBestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
@@ -602,8 +616,10 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
@@ -632,8 +648,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
baseUserPair?.let {
ClickableUserTag(it.first, navController)
Text(text = "${it.second} ")
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = "${it.second} ",
tags = userTags,
route = route,
navController = navController
)
}
baseNotePair?.let {

View File

@@ -16,6 +16,7 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
import java.util.Date
@Composable
fun RobohashAsyncImage(
@@ -36,7 +37,7 @@ fun RobohashAsyncImage(
robotSize.roundToPx()
}
val imageRequest = remember(size, robot) {
val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest(
context,
robot,
@@ -58,7 +59,7 @@ fun RobohashAsyncImage(
)
}
var imageErrors = setOf<String>()
var imageErrors = mapOf<String, Long>()
@Composable
fun RobohashFallbackAsyncImage(
@@ -73,7 +74,9 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (imageErrors.contains(model)) {
val errorCache = remember(imageErrors) { imageErrors[model] }
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
@@ -110,7 +113,7 @@ fun RobohashFallbackAsyncImage(
colorFilter = colorFilter,
filterQuality = filterQuality,
onError = {
imageErrors = imageErrors + model
imageErrors = imageErrors + Pair(model, Date().time / 1000)
}
)
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -41,13 +42,15 @@ fun UrlPreviewCard(
)
) {
Column {
val validatedUrl = URL(previewInfo.url)
val validatedUrl = remember { URL(previewInfo.url) }
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
val imageUrl = remember {
if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
}
AsyncImage(

View File

@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.components
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@@ -55,6 +60,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var muted = mutableStateOf(true)
@@ -62,20 +69,55 @@ public var muted = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog)
VideoView(localFile.toUri(), description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, onDialog)
VideoView(Uri.parse(videoUri), description, null, onDialog)
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(context).data(thumbUri).build()
val myCover = context.imageLoader.execute(request).drawable
if (myCover != null) {
loadingFinished = Pair(true, myCover)
} else {
loadingFinished = Pair(true, null)
}
} catch (e: Exception) {
Log.e("VideoView", "Fail to load cover $thumbUri", e)
loadingFinished = Pair(true, null)
}
}
}
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog)
} else {
VideoView(videoUri, description, onDialog)
}
}
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
println("loading audio with artwork $thumb")
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -89,7 +131,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
@@ -116,9 +158,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else {
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
}
},
@@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
@@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
muted.value = !muted.value
}
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -175,7 +217,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
val newIsVisible = coordinates.isCompletelyVisible(view)
if (isVisible != newIsVisible) {
isVisible = newIsVisible
}
}
}

View File

@@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -209,17 +211,21 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
if (ratio != null) {
it.aspectRatio(ratio, maxHeight.isFinite)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
if (content.localFile != null && content.localFile.exists()) {
AsyncImage(
@@ -280,12 +286,20 @@ private fun UrlImageView(
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, maxHeight.isFinite)
} ?: this
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
AsyncImage(
model = content.url,

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
return collection
.asSequence()
.filter {
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
(it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
val channel = it.channelHex()

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
@@ -40,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()
}
}

View File

@@ -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 {

View File

@@ -19,7 +19,7 @@ object PeopleListFeedFilter : FeedFilter<Note>() {
.toSet()
return lists
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -14,7 +14,7 @@ object UserProfileReportsFeedFilter : FeedFilter<Note>() {
return user?.reports
?.values
?.flatten()
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
}
}

View File

@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet(
val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let {
Text(it)
CreateTextWithEmoji(
text = it,
tags = user.info?.latestMetadata?.tags
)
}
Text(npubShortHex)

View File

@@ -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,8 @@ 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.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -50,7 +54,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 +72,16 @@ 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()
}
}
NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
@@ -135,6 +150,7 @@ fun AppNavigation(
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
navController = navController,
scrollToTop = scrollToTop

View File

@@ -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
@@ -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()

View File

@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
@@ -166,8 +169,9 @@ fun ProfileContent(
})
)
if (accountUser.bestDisplayName() != null) {
Text(
accountUser.bestDisplayName() ?: "",
CreateTextWithEmoji(
text = accountUser.bestDisplayName() ?: "",
tags = accountUser.info?.latestMetadata?.tags,
modifier = Modifier
.padding(top = 7.dp)
.clickable(onClick = {
@@ -183,19 +187,22 @@ fun ProfileContent(
)
}
if (accountUser.bestUsername() != null) {
Text(
" @${accountUser.bestUsername()}",
CreateTextWithEmoji(
text = " @${accountUser.bestUsername()}",
tags = accountUser.info?.latestMetadata?.tags,
color = Color.LightGray,
modifier = Modifier
.padding(top = 15.dp)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
.clickable(
onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
)
}
Row(
@@ -294,6 +301,12 @@ fun ListContent(
title = textTorProxy,
icon = R.drawable.ic_tor,
tint = MaterialTheme.colors.onBackground,
onLongClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
conectOrbotDialogOpen = true
},
onClick = {
if (checked) {
disconnectTorDialog = true
@@ -401,12 +414,16 @@ fun NavigationRow(
})
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) {
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
) {
Row(
modifier = Modifier

View File

@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
@@ -39,7 +39,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.post_not_found),
text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "",
modifier = Modifier.padding(30.dp),
color = Color.Gray
)

View File

@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -55,9 +56,12 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -65,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@@ -80,27 +85,54 @@ fun ChatroomMessageCompose(
navController: NavController,
onWantsToReply: (Note) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
val accountUser = account.userProfile()
val noteEvent = remember(noteState) { note.event }
var popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
if (noteEvent == null) {
BlankNote(
Modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
)
if (note?.event == null) {
BlankNote(Modifier)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
}
} else if (account.isHidden(noteForReports.author!!)) {
// Does nothing
} else {
var showHiddenNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
withContext(Dispatchers.IO) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author === loggedIn ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
val newIsAcceptable = account.isAcceptable(noteForReports)
if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) {
isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview)
}
}
}
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
@@ -109,299 +141,436 @@ fun ChatroomMessageCompose(
navController,
onClick = { showHiddenNote = true }
)
}
} else {
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
val backgroundBubbleColor: Color
val alignment: Arrangement.Horizontal
val shape: Shape
if (parentBackgroundColor != null) {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor)
} else {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background)
}
if (note.author == loggedIn) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
var isNew by remember { mutableStateOf<Boolean>(false) }
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
isNew = createdAt > lastTime
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
}
}
}
}
}
Column() {
val modif = if (innerQuote) {
Modifier.padding(top = 10.dp, end = 5.dp)
} else {
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
)
}
Row(
modifier = modif,
horizontalArrangement = alignment
) {
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
Column() {
val modif = remember {
if (innerQuote) {
Modifier.padding(top = 10.dp, end = 5.dp)
} else {
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
)
}
}
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize = it
}
modifier = modif,
horizontalArrangement = alignment
) {
Surface(
color = backgroundBubbleColor,
shape = shape,
modifier = Modifier
.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize = it
}
) {
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
Column(
Surface(
color = backgroundBubbleColor,
shape = shape,
modifier = Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
.onSizeChanged {
bubbleSize = it
}
.combinedClickable(
onClick = {
if (noteEvent is ChannelCreateEvent) {
navController.navigate("Channel/${note.idHex}")
}
},
onLongClick = { popupExpanded = true }
)
) {
val authorState by note.author!!.live().metadata.observeAsState()
val author = authorState?.user!!
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = ResizeImage(author.profilePicture(), 25.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
author.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
Text(
" ${author.toBestDisplayName()}",
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(onClick = {
author.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
}
}
val replyTo = note.replyTo
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.toSet().mapIndexed { _, note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
Column(
modifier = Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
.onSizeChanged {
bubbleSize = it
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
channelInfo.picture
?: ""
) + "'"
)
} else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
channelInfo.name
?: ""
) + "$', {stringResource(R.string.description_to)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
channelInfo.picture
?: ""
) + "'"
) {
if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
DrawAuthorInfo(
baseNote,
alignment,
navController
)
} else {
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == accountUser ||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
navController
)
} else {
TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
navController
)
}
Spacer(modifier = Modifier.height(5.dp))
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 5.dp)
.then(
with(LocalDensity.current) {
Modifier.widthIn(
bubbleSize.width.toDp(),
availableBubbleSize.width.toDp()
val replyTo = note.replyTo
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.lastOrNull()?.let { note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
)
) {
Row() {
Text(
timeAgoShort(note.createdAt(), context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
RelayBadges(note)
Spacer(modifier = Modifier.width(10.dp))
}
}
Row() {
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
Row(verticalAlignment = Alignment.CenterVertically) {
when (noteEvent) {
is ChannelCreateEvent -> {
RenderCreateChannelNote(note)
}
is ChannelMetadataEvent -> {
RenderChangeChannelMetadataNote(note)
}
else -> {
RenderRegularTextNote(
note,
loggedIn,
isAcceptableAndCanPreview.second,
backgroundBubbleColor,
accountViewModel,
navController
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 5.dp)
.then(
with(LocalDensity.current) {
Modifier.widthIn(
bubbleSize.width.toDp(),
availableBubbleSize.width.toDp()
)
}
)
) {
StatusRow(
baseNote,
accountViewModel,
onWantsToReply
)
}
}
}
}
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(
note,
popupExpanded,
{ popupExpanded = false },
accountViewModel
)
}
}
}
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
private fun StatusRow(
baseNote: Note,
accountViewModel: AccountViewModel,
onWantsToReply: (Note) -> Unit
) {
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val time = remember { baseNote.createdAt() ?: 0 }
var expanded by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
ChatTimeAgo(time)
RelayBadges(baseNote)
Spacer(modifier = Modifier.width(10.dp))
}
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
if (noteRelays.size > 3 && !expanded) {
IconButton(
modifier = Modifier.then(Modifier.size(15.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) {
onWantsToReply(baseNote)
}
}
}
@Composable
fun ChatTimeAgo(time: Long) {
val context = LocalContext.current
var timeStr by remember { mutableStateOf("") }
LaunchedEffect(key1 = time) {
withContext(Dispatchers.IO) {
timeStr = timeAgoShort(time, context = context)
}
}
Text(
timeStr,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
}
@Composable
private fun RenderRegularTextNote(
note: Note,
loggedIn: User,
canPreview: Boolean,
backgroundBubbleColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val tags = remember { note.event?.tags() }
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
modifier,
tags,
backgroundBubbleColor,
accountViewModel,
navController
)
} else {
TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
modifier,
tags,
backgroundBubbleColor,
accountViewModel,
navController
)
}
}
@Composable
private fun RenderChangeChannelMetadataNote(
note: Note
) {
val noteEvent = note.event as? ChannelMetadataEvent ?: return
val channelInfo = noteEvent.channelInfo()
val text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
channelInfo.name
?: ""
) + "', ${stringResource(R.string.description_to)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
channelInfo.picture
?: ""
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
)
}
@Composable
private fun RenderCreateChannelNote(note: Note) {
val noteEvent = note.event as? ChannelCreateEvent ?: return
val channelInfo = remember { noteEvent.channelInfo() }
val text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
channelInfo.picture
?: ""
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
)
}
@Composable
private fun DrawAuthorInfo(
baseNote: Note,
alignment: Arrangement.Horizontal,
navController: NavController
) {
val userState by baseNote.author!!.live().metadata.observeAsState()
val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return
val route = remember { "User/$pubkeyHex" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
RobohashAsyncImageProxy(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
navController.navigate(route)
})
)
CreateClickableTextWithEmoji(
clickablePart = " $userDisplayName",
suffix = "",
tags = userTags,
fontWeight = FontWeight.Bold,
overrideColor = MaterialTheme.colors.onBackground,
route = route,
navController = navController
)
}
}
data class RelayBadgesState(
val shouldDisplayExpandButton: Boolean,
val noteRelays: List<String>,
val noteRelaysSimple: List<String>
)
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val state: RelayBadgesState by remember(noteRelaysState) {
val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3
val noteRelays = noteRelaysState?.note?.relays?.toList() ?: emptyList()
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toList() ?: emptyList()
mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple))
}
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay by remember {
derivedStateOf {
if (expanded) state.noteRelays else state.noteRelaysSimple
}
}
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
RenderRelay(it)
}
}
if (state.shouldDisplayExpandButton && !expanded) {
IconButton(
modifier = Modifier.then(Modifier.size(15.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
@Composable
private fun RenderRelay(dirtyUrl: String) {
val uri = LocalUriHandler.current
val website = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
"https://$cleanUrl"
}
val iconUrl = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
"https://$cleanUrl/favicon.ico"
}
val clickableModifier = remember {
Modifier
.size(15.dp)
.padding(1.dp)
.clickable(onClick = { uri.openUri(website) })
}
val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
}
val iconModifier = remember {
Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
}
Box(
modifier = clickableModifier
) {
RobohashFallbackAsyncImage(
robot = iconUrl,
robotSize = 15.dp,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = colorFilter,
modifier = iconModifier.background(MaterialTheme.colors.background)
)
}
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -87,6 +89,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -97,7 +100,9 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -106,13 +111,14 @@ import com.vitorpamplona.amethyst.service.model.ReportedKey
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableText
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContent
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage
@@ -132,6 +138,7 @@ import nostr.postr.toNpub
import java.io.File
import java.math.BigDecimal
import java.net.URL
import java.util.Locale
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -189,13 +196,13 @@ fun NoteComposeInner(
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
val note = remember(noteState) { noteState?.note } ?: return
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
val noteEvent = note?.event
val baseChannel = note?.channel()
val noteEvent = remember(noteState) { note.event }
val baseChannel = remember(noteState) { note.channel() }
var popupExpanded by remember { mutableStateOf(false) }
@@ -210,9 +217,11 @@ fun NoteComposeInner(
isBoostedNote
)
note?.let {
note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
}
} else if (account.isHidden(noteForReports.author!!)) {
// Does nothing
} else {
var showHiddenNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
@@ -234,16 +243,14 @@ fun NoteComposeInner(
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
}
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) {
@@ -376,6 +383,14 @@ fun NoteComposeInner(
RenderPeopleList(noteState, backgroundColor, accountViewModel, navController)
}
is AudioTrackEvent -> {
RenderAudioTrack(note, loggedIn, accountViewModel, navController)
}
is PinListEvent -> {
RenderPinListEvent(noteState, backgroundColor, accountViewModel, navController)
}
is PrivateDmEvent -> {
RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController)
}
@@ -868,6 +883,135 @@ private fun RenderRepost(
}
}
@Composable
private fun RenderPinListEvent(
noteState: NoteState?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val noteEvent = noteState?.note?.event as? PinListEvent ?: return
PinListHeader(noteState, backgroundColor, accountViewModel, navController)
ReactionsRow(noteState?.note, accountViewModel, navController)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
@Composable
fun PinListHeader(
noteState: NoteState?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val note = remember(noteState) { noteState?.note } ?: return
val noteEvent = note.event as? PinListEvent ?: return
var pins by remember { mutableStateOf<List<String>>(noteEvent.pins()) }
var expanded by remember {
mutableStateOf(false)
}
val pinsToShow = if (expanded) {
pins
} else {
pins.take(3)
}
Text(
text = "#${noteEvent.dTag()}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
pinsToShow.forEach { pin ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
tint = MaterialTheme.colors.onBackground.copy(0.32f),
modifier = Modifier.size(15.dp)
)
Spacer(modifier = Modifier.width(5.dp))
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = emptyList(),
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
if (pins.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { expanded = !expanded },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}
}
}
@Composable
private fun RenderAudioTrack(
note: Note,
loggedIn: User,
accountViewModel: AccountViewModel,
navController: NavController
) {
val noteEvent = note.event as? AudioTrackEvent ?: return
AudioTrackHeader(noteEvent, note, loggedIn, navController)
ReactionsRow(note, accountViewModel, navController)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
@Composable
private fun RenderLongFormContent(
note: Note,
@@ -1217,13 +1361,15 @@ fun DisplayHighlight(
val userState by userBase.live().metadata.observeAsState()
val route = remember { "User/${userBase.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
if (userDisplayName != null) {
CreateClickableText(
userDisplayName,
"",
route,
navController
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "",
tags = userTags,
route = route,
navController = navController
)
}
}
@@ -1511,45 +1657,124 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
}
}
val noteState = fileNote?.live()?.metadata?.observeAsState()
val note = noteState?.value?.note
fileNote?.let { fileNote2 ->
val noteState by fileNote2.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
var content by remember { mutableStateOf<ZoomableContent?>(null) }
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
withContext(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
withContext(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
content = if (mimeType?.startsWith("image") == true) {
ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
}
}
}
content?.let {
ZoomableContentView(content = it, listOf(it))
}
}
}
@Composable
fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, loggedIn: User, navController: NavController) {
val media = remember { noteEvent.media() }
val cover = remember { noteEvent.cover() }
val subject = remember { noteEvent.subject() }
val content = remember { noteEvent.content() }
val participants = remember { noteEvent.participants() }
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
LaunchedEffect(key1 = participants) {
withContext(Dispatchers.IO) {
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
}
}
content?.let {
ZoomableContentView(content = it, listOf(it))
Row(modifier = Modifier.padding(top = 5.dp)) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Row() {
subject?.let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
Text(
text = it,
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
}
participantUsers.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
.clickable {
navController.navigate("User/${it.second.pubkeyHex}")
}
) {
UserPicture(it.second, loggedIn, 25.dp)
Spacer(Modifier.width(5.dp))
UsernameDisplay(it.second, Modifier.weight(1f))
Spacer(Modifier.width(5.dp))
it.first.role?.let {
Text(
text = it.capitalize(Locale.ROOT),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
}
}
}
media?.let { media ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp)
) {
cover?.let { cover ->
VideoView(
videoUri = media,
description = noteEvent.subject(),
thumbUri = cover
)
}
?: VideoView(
videoUri = media,
noteEvent.subject()
)
}
}
}
}
}
@@ -1716,7 +1941,7 @@ private fun RelayIconCompose(url: String) {
.clickable(onClick = { uri.openUri("https://$url") })
}
val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
}
Box(boxModifier) {
@@ -1958,13 +2183,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false)
)
}
LaunchedEffect(key1 = note) {
LaunchedEffect(key1 = note, key2 = bookmarkState) {
withContext(Dispatchers.IO) {
state = DropDownParams(
accountViewModel.isFollowing(note.author),

View File

@@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note ->
if (note is AddressableNote) {
if (note.event?.getReward() != null) {
"https://nostrbounties.com/b/${note.address().toNAddr()}"
} else if (note.event is PeopleListEvent) {
"https://listr.lol/a/${note.address()?.toNAddr()}"
} else if (note.event is AudioTrackEvent) {
"https://zapstr.live/?track=${note.address()?.toNAddr()}"
} else {
"https://habla.news/a/${note.address().toNAddr()}"
"https://habla.news/a/${note.address()?.toNAddr()}"
}
} else {
"https://snort.social/e/${note.toNEvent()}"
if (note.event is FileHeaderEvent) {
"https://filestr.vercel.app/e/${note.toNEvent()}"
} else {
"https://snort.social/e/${note.toNEvent()}"
}
}
}
@@ -128,7 +139,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
if (popupExpanded) {
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss) {
Card(
@@ -206,10 +217,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.text_select_move_forward_character),
label = stringResource(R.string.quick_action_select)
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
showSelectTextDialog = true
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
VerticalDivider(primaryLight)

View File

@@ -18,6 +18,7 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
@@ -196,8 +198,9 @@ fun ReplyInformationChannel(
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)

View File

@@ -0,0 +1,351 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel
import com.patrykandpatrick.vico.core.entry.ChartEntryModel
import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.patrykandpatrick.vico.core.entry.entryOf
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, onClick: () -> Unit) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.clickable(onClick = onClick)
.padding(10.dp)
) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) {
Text(
text = stringResource(id = R.string.today),
fontWeight = FontWeight.Bold
)
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserReplyReaction(model.replies[model.today])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserBoostReaction(model.boosts[model.today])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserLikeReaction(model.reactions[model.today])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserZapReaction(model.zaps[model.today])
}
}
}
class UserReactionsViewModel : ViewModel() {
var user: User? = null
var reactions by mutableStateOf<Map<String, Int>>(emptyMap())
var boosts by mutableStateOf<Map<String, Int>>(emptyMap())
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap())
var takenIntoAccount = setOf<HexKey>()
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val today = sdf.format(LocalDateTime.now())
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(emptyList())
fun load(baseUser: User) {
user = baseUser
reactions = emptyMap()
boosts = emptyMap()
zaps = emptyMap()
replies = emptyMap()
takenIntoAccount = emptySet()
}
fun formatDate(createAt: Long): String {
return sdf.format(
Instant.ofEpochSecond(createAt)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
}
fun initializeSuspend() {
val currentUser = user?.pubkeyHex ?: return
val reactions = mutableMapOf<String, Int>()
val boosts = mutableMapOf<String, Int>()
val zaps = mutableMapOf<String, BigDecimal>()
val replies = mutableMapOf<String, Int>()
val takenIntoAccount = mutableSetOf<HexKey>()
LocalCache.notes.values.forEach {
val noteEvent = it.event
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
if (noteEvent is ReactionEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
}
} else if (noteEvent is RepostEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
}
} else if (noteEvent is TextNoteEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
replies[netDate] = (replies[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
}
}
}
}
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
refreshChartModel()
}
fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user?.pubkeyHex ?: return
val reactions = this.reactions.toMutableMap()
val boosts = this.boosts.toMutableMap()
val zaps = this.zaps.toMutableMap()
val replies = this.replies.toMutableMap()
val takenIntoAccount = this.takenIntoAccount.toMutableSet()
var hasNewElements = false
newNotes.forEach {
val noteEvent = it.event
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
if (noteEvent is ReactionEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is RepostEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is TextNoteEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
replies[netDate] = (replies[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
}
}
}
if (hasNewElements) {
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
refreshChartModel()
}
}
private fun refreshChartModel() {
val day = 24 * 60 * 60L
val now = LocalDateTime.now()
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) }
val listOfCountCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) }
)
val listOfValueCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) }
)
val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel()
val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel()
this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) }
this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2)
}
var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
addToStatsSuspend(newNotes)
}
}
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
}
@Composable
fun UserReplyReaction(
replyCount: Int?
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(20.dp),
tint = Color.Cyan
)
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(replyCount),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
@Composable
fun UserBoostReaction(
boostCount: Int?
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(boostCount),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
@Composable
fun UserLikeReaction(
likeCount: Int?
) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(likeCount),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
@Composable
fun UserZapReaction(
amount: BigDecimal?
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(24.dp),
tint = BitcoinOrange
)
Spacer(modifier = Modifier.width(8.dp))
Text(
showAmount(amount),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}

View File

@@ -5,34 +5,31 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = remember(noteState) { noteState?.note?.author } ?: return
val author = note.author
if (author != null) {
UsernameDisplay(author, weight)
}
UsernameDisplay(author, weight)
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val bestUserName = user.bestUsername()
val bestDisplayName = user.bestDisplayName()
val npubDisplay = user.pubkeyDisplayHex()
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@Composable
@@ -40,31 +37,36 @@ private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
tags: List<List<String>>?,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
Text(
bestDisplayName,
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold
)
Text(
"@$bestUserName",
CreateTextWithEmoji(
text = "@$bestUserName",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
} else if (bestDisplayName != null) {
Text(
bestDisplayName,
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
} else if (bestUserName != null) {
Text(
"@$bestUserName",
CreateTextWithEmoji(
text = "@$bestUserName",
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
@@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 5.dp),
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
CreateTextWithEmoji(
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
tags = user.info?.latestMetadata?.tags,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)

View File

@@ -59,7 +59,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
if (newCards.isNotEmpty()) {
lastNotes = notes
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedWith(compareBy({ it.createdAt() }, { it.id() })).reversed())
}
} else {
val cards = convertToCard(notes)
@@ -129,7 +129,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedBy { it.createdAt() }.reversed()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
singleList.chunked(50).map { chunk ->
MultiSetCard(
baseNote,
@@ -157,7 +157,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards + userZaps).sortedWith(compareBy({ it.createdAt() }, { it.id() })).reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -190,7 +190,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
if (newCards.isNotEmpty()) {
lastNotes = lastNotesCopy + newItems
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedWith(compareBy({ it.createdAt() }, { it.id() })).reversed())
}
} else {
// Refresh Everything

View File

@@ -53,10 +53,12 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -352,6 +354,10 @@ fun NoteMaster(
Column() {
if (noteEvent is PeopleListEvent) {
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, note, account.userProfile(), navController)
} else if (noteEvent is PinListEvent) {
PinListHeader(noteState, MaterialTheme.colors.background, accountViewModel, navController)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -253,7 +254,11 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont
val context = LocalContext.current.applicationContext
Column() {
Column(
Modifier.clickable {
navController.navigate("Channel/${baseChannel.idHex}")
}
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy(

View File

@@ -1,29 +1,65 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.endAxis
import com.patrykandpatrick.vico.compose.axis.vertical.startAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush
import com.patrykandpatrick.vico.compose.style.ProvideChartStyle
import com.patrykandpatrick.vico.core.DefaultAlpha
import com.patrykandpatrick.vico.core.axis.AxisPosition
import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter
import com.patrykandpatrick.vico.core.chart.composed.plus
import com.patrykandpatrick.vico.core.chart.line.LineChart
import com.patrykandpatrick.vico.core.chart.values.ChartValues
import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.OneGiga
import com.vitorpamplona.amethyst.ui.note.OneKilo
import com.vitorpamplona.amethyst.ui.note.OneMega
import com.vitorpamplona.amethyst.ui.note.UserReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.screen.CardFeedView
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@Composable
fun NotificationScreen(
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
scrollToTop: Boolean = false
@@ -61,6 +97,7 @@ fun NotificationScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SummaryBar(userReactionsStatsModel, accountViewModel, navController)
CardFeedView(
viewModel = notifFeedViewModel,
accountViewModel = accountViewModel,
@@ -72,3 +109,121 @@ fun NotificationScreen(
}
}
}
@Composable
fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController) {
var showChart by remember {
mutableStateOf(false)
}
UserReactionsRow(model, accountViewModel, navController) {
showChart = !showChart
}
val lineChartCount =
lineChart(
lines = listOf(Color.Cyan, Color.Green, Color.Red).map { lineChartColor ->
LineChart.LineSpec(
lineColor = lineChartColor.toArgb(),
lineBackgroundShader = DynamicShaders.fromBrush(
Brush.verticalGradient(
listOf(
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START),
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END)
)
)
)
)
},
targetVerticalAxisPosition = AxisPosition.Vertical.Start
)
val lineChartZaps =
lineChart(
lines = listOf(BitcoinOrange).map { lineChartColor ->
LineChart.LineSpec(
lineColor = lineChartColor.toArgb(),
lineBackgroundShader = DynamicShaders.fromBrush(
Brush.verticalGradient(
listOf(
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START),
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END)
)
)
)
)
},
targetVerticalAxisPosition = AxisPosition.Vertical.End
)
model.chartModel?.let {
if (showChart) {
Row(
modifier = Modifier
.padding(vertical = 10.dp, horizontal = 20.dp)
.clickable(onClick = { showChart = !showChart })
) {
ProvideChartStyle() {
Chart(
chart = remember(lineChartCount, lineChartZaps) {
lineChartCount.plus(lineChartZaps)
},
model = it,
startAxis = startAxis(
valueFormatter = CountAxisValueFormatter()
),
endAxis = endAxis(
valueFormatter = AmountAxisValueFormatter()
),
bottomAxis = bottomAxis(
valueFormatter = LabelValueFormatter(model.axisLabels)
)
)
}
}
}
}
Divider(
thickness = 0.25.dp
)
}
class LabelValueFormatter(val axisLabels: List<String>) : AxisValueFormatter<AxisPosition.Horizontal.Bottom> {
override fun formatValue(
value: Float,
chartValues: ChartValues
): String {
return axisLabels[value.roundToInt()]
}
}
class CountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.Start> {
override fun formatValue(
value: Float,
chartValues: ChartValues
): String {
return showCount(value.roundToInt())
}
}
class AmountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.End> {
override fun formatValue(
value: Float,
chartValues: ChartValues
): String {
return showAmountAxis(value.toBigDecimal())
}
}
fun showAmountAxis(amount: BigDecimal?): String {
if (amount == null) return ""
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> "%.0fG".format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> "%.0fM".format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= OneKilo -> "%.0fk".format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> "%.0f".format(amount)
}
}

View File

@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@@ -470,29 +471,36 @@ private fun ProfileActions(
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
Row(verticalAlignment = Alignment.Bottom) {
user.bestDisplayName()?.let {
Text(
it,
modifier = Modifier.padding(top = 7.dp),
(user.bestDisplayName() ?: user.bestUsername())?.let {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = tags,
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (user.bestDisplayName() != null) {
user.bestUsername()?.let {
Text(
"@$it",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
)
) {
CreateTextWithEmoji(
text = "@$it",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}

View File

@@ -131,6 +131,8 @@ fun SearchScreen(
}
class SearchBarViewModel : ViewModel() {
var account: Account? = null
var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
@@ -156,8 +158,8 @@ class SearchBarViewModel : ViewModel() {
}
hashtagResults.value = findHashtags(searchValue)
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed()
searchResults.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
}
@@ -185,6 +187,7 @@ class SearchBarViewModel : ViewModel() {
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.accountLiveData.value?.account
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -335,7 +338,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
}
}
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)")
fun findHashtags(content: String): List<String> {
val matcher = hashtagSearch.matcher(content)

View File

@@ -275,7 +275,7 @@ private fun RenderVideoOrPictureNote(
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)

View File

@@ -284,6 +284,8 @@
<string name="looking_for_event">"%1$s esemény keresése"</string>
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
<string name="custom_zaps_add_a_message_private">Adj hozzá egy privát üzenetet</string>
<string name="custom_zaps_add_a_message_nonzap">A számlához adj hozzá egy üzenetet</string>
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
@@ -334,6 +336,13 @@
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
<string name="connect_via_tor_short">Tor/Orbot beállítása</string>
<string name="connect_via_tor">Csatlakozás a saját Orbot beállításod alapján</string>
<string name="do_you_really_want_to_disable_tor_title">Lecsatlakozás a saját Orbot/Tor hálózatról?</string>
<string name="do_you_really_want_to_disable_tor_text">Az adataid azonnal a hagyományos hálózaton lesz továbbítva</string>
<string name="yes">Igen</string>
<string name="no">Nem</string>
<string name="follow_list_selection">Követek Lista</string>
@@ -374,4 +383,6 @@
<string name="channel_list_user_or_group_id_demo">npub, nevent vagy hex</string>
<string name="channel_list_create_channel">Létrehoz</string>
<string name="channel_list_join_channel">Csatlakozás</string>
<string name="today">Ma</string>
</resources>

408
app/src/main/res/values-sv Normal file
View File

@@ -0,0 +1,408 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name_release" translatable="false">Amethyst</string>
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
<string name="point_to_the_qr_code">Point to the QR Code</string>
<string name="show_qr">Visa QR</string>
<string name="profile_image">Profilbild</string>
<string name="scan_qr">Scanna QR</string>
<string name=”show_anyway">Visa ändå</string>
<string name="post_was_flagged_as_inappropriate_by">Inlägget flaggades som olämpligt av</string>
<string name=”post_not_found">Inlägg hittades inte</string>
<string name=”channel_image">Kanal bild</string>
<string name="referenced_event_not_found">Refererad händelse hittades inte</string>
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
<string name=”group_picture">Grupp bild</string>
<string name="explicit_content">Explicit Innehåll</string>
<string name="spam">Spam</string>
<string name="impersonation">Imitation</string>
<string name="illegal_behavior">Olagligt beteende</string>
<string name="unknown">Okänd</string>
<string name=”relay_icon">Relä ikon</string>
<string name="unknown_author">Okänd användare</string>
<string name=”copy_text">Kopiera Text</string>
<string name=”copy_user_pubkey">Kopiera användarens ID</string>
<string name=”copy_note_id">Kopiera anteckningens ID</string>
<string name=”broadcast">Sänd ut</string>
<string name="request_deletion">Begär radering</string>
<string name=”block_hide_user"><![CDATA[Blockera och göm användare]]></string>
<string name="report_spam_scam">Rapportera Spam / Scam</string>
<string name="report_impersonation">Rapportera identitetsstöld</string>
<string name="report_explicit_content">Rapportera explicit innehåll</string>
<string name="report_illegal_behaviour">Rapportera olagligt beteende</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Logga in med en privat nyckel för att kunna svara</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Logga in med en privat nyckel för att kunna boosta inlägg</string>
<string name="login_with_a_private_key_to_like_posts">Logga in med en privat nyckel för att gilla inlägg</string>
<string name="no_zap_amount_setup_long_press_to_change">Ingen konfiguration av Zap-belopp. Tryck länge för att ändra</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Logga in med en privat nyckel för att kunna skicka Zaps</string>
<string name="zaps">Zaps</string>
<string name="view_count">Antal visningar</string>
<string name="boost">Boost</string>
<string name="boosted">boosted</string>
<string name="quote">Citera</string>
<string name="new_amount_in_sats">Nytt belopp i Sats</string>
<string name=”add">Lägg till</string>
<string name=”replying_to">"Svarar till "</string>
<string name="and">" och "</string>
<string name="in_channel">"i kanalen "</string>
<string name="profile_banner">Profil Banner</string>
<string name="following">" Följer"</string>
<string name="followers">" Följare"</string>
<string name="profile">Profil</string>
<string name="security_filters">Säkerhets Filter</string>
<string name=”log_out">Logga ut</string>
<string name=”show_more">Visa mer</string>
<string name="lightning_invoice">Lightning Faktura</string>
<string name="pay">Betala</string>
<string name="lightning_tips">Lightning Dricks</string>
<string name="note_to_receiver">Notering till mottagaren</string>
<string name=”thank_you_so_much">Tack så mycket!</string>
<string name="amount_in_sats">Summa i Sats</string>
<string name="send_sats">Skicka Sats</string>
<string name="error_parsing_preview_for">"Fel vid analys av förhandsvisning för %1$s : %2$s"</string>
<string name="preview_card_image_for">"Förhandsgranska kortbild för %1$s"</string>
<string name=”new_channel">Ny kanal</string>
<string name=”channel_name">Kanal Namn</string>
<string name="my_awesome_group">Min fantastiska grupp</string>
<string name="picture_url">Bild Url</string>
<string name="description">Beskrivning</string>
<string name=”about_us">"Om oss.. "</string>
<string name=”what_s_on_your_mind">Vad tänker du på?</string>
<string name="post">Dela</string>
<string name="save">Spara</string>
<string name="create">Skapa</string>
<string name="cancel">Avbryt</string>
<string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string>
<string name="relay_address">Relä Adress</string>
<string name="posts">Inlägg</string>
<string name="bytes">Bytes</string>
<string name="errors">Errors</string>
<string name=”home_feed">Hem Flöde</string>
<string name=”private_message_feed">Privata Meddelande Flöde</string>
<string name="public_chat_feed">Publik Chat Flöde</string>
<string name="global_feed">Globalt Flöde</string>
<string name="search_feed">Sök i Flödet</string>
<string name=”add_a_relay">Lägg till Relä</string>
<string name="display_name">Visningsnamn</string>
<string name=”my_display_name">Mitt visningsnamn</string>
<string name="username">Användarnamn</string>
<string name=”my_username">Mitt användarnamn</string>
<string name=”about_me">Om mig</string>
<string name="avatar_url">Avatar URL</string>
<string name="banner_url">Banner URL</string>
<string name="website_url">Websida URL</string>
<string name="ln_address">LN Adress</string>
<string name="ln_url_outdated">LN URL (Utdaterad)</string>
<string name=”image_saved_to_the_gallery">Bild sparad till galleriet</string>
<string name=”failed_to_save_the_image">Misslyckades att spara bilden</string>
<string name=”upload_image">Ladda upp bilden</string>
<string name=”uploading">Laddar upp…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
<string name=”reply_here">"svara här.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiera kanal-ID (anteckningen) till Urklipp</string>
<string name="edits_the_channel_metadata">Redigerar kanalmetadata</string>
<string name=”join">Gå med</string>
<string name="known">Känd</string>
<string name="new_requests">Nya förfrågningar</string>
<string name="blocked_users">Blockerade användare</string>
<string name="new_threads">Nya trådar</string>
<string name="conversations">konversationer</string>
<string name="notes">Anteckningar</string>
<string name="replies">Svar</string>
<string name="follows">"Följer"</string>
<string name="reports">"Rapporter"</string>
<string name="more_options">Fler alternativ</string>
<string name="relays">" Reläer"</string>
<string name="website">Websida</string>
<string name="lightning_address">Lightning Adress</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering</string>
<string name="copy_private_key_to_the_clipboard">Kopiera hemlig nyckel till urklipp</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Kopierar den publika nyckeln till urklipp för delning</string>
<string name="copy_public_key_npub_to_the_clipboard">Kopiera Public Key (NPub) till Urklipp</string>
<string name=”send_a_direct_message">Skicka ett direkt meddelande</string>
<string name="edits_the_user_s_metadata">Redigerar användarens metadata</string>
<string name="follow">Följ</string>
<string name=”follow_back">Följ tillbaka</string>
<string name=”unblock”>Sluta blockera</string>
<string name="copy_user_id">Kopiera Användarens ID</string>
<string name=”unblock_user”>Sluta blockera Användare</string>
<string name="npub_hex_username">"npub, hex, användarnamn "</string>
<string name="clear">Rensa</string>
<string name="app_logo">App Logo</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex privat nyckel</string>
<string name=”show_password">Visa lösenord</string>
<string name=”hide_password">Göm lösenord</string>
<string name="invalid_key">Ogiltig nyckel</string>
<string name=”i_accept_the">"Jag accepterar "</string>
<string name="terms_of_use">villkor</string>
<string name="acceptance_of_terms_is_required">Godkännande av villkor krävs</string>
<string name="key_is_required">Nyckel krävs</string>
<string name=”login">Inloggning</string>
<string name="generate_a_new_key">Skapa en ny nyckel</string>
<string name=”loading_feed">Ladda flöde</string>
<string name="error_loading_replies">"Det gick inte att läsa in svar: "</string>
<string name=”try_again">Försök igen</string>
<string name=”feed_is_empty">Flöde är tomt.</string>
<string name="refresh">Uppdatera</string>
<string name="created">Skapad</string>
<string name="with_description_of">med beskrivning av</string>
<string name=”and_picture">och bild</string>
<string name="changed_chat_name_to">ändra chat namn till</string>
<string name="description_to">beskrivning till</string>
<string name="and_picture_to">och bild till</string>
<string name="leave">Lämna</string>
<string name=”unfollow">Sluta följa</string>
<string name=”channel_created">Kanal skapad</string>
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
<string name="public_chat">Publik Chat</string>
<string name="posts_received">mottagna inlägg</string>
<string name=”remove">Ta bort</string>
<string name="sats" translatable="false">sats</string>
<string name="translations_auto">Auto</string>
<string name=”translations_translated_from">översatt från</string>
<string name="translations_to">till</string>
<string name=”translations_show_in_lang_first">Visa i %1$s first</string>
<string name=”translations_always_translate_to_lang">Översätt alltid till %1$s</string>
<string name=”translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
<string name="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">aldrig</string>
<string name="now">nu</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Naket</string>
<string name="profanity_hateful_speech">Svordomar / Hatiskt tal</string>
<string name="report_hateful_speech">Anmäl hattal</string>
<string name="report_nudity_porn">Rapportera nakenhet/porr</string>
<string name="others">andras</string>
<string name="mark_all_known_as_read">Markera alla kända som lästa</string>
<string name="mark_all_new_as_read">Markera alla nya som lästa</string>
<string name="mark_all_as_read">Markera allt som läst</string>
<string name="backup_keys">Backup-nycklar</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## Nyckelsäkerhetskopiering och säkerhetstips
\n\nDitt konto är skyddat av en hemlig nyckel. Nyckeln är en lång slumpmässig sträng som börjar med **nsec1**. Alla som har tillgång till din hemliga nyckel kan publicera innehåll med din identitet.
\n\n- Lägg **inte** in din hemliga nyckel på någon webbplats eller programvara som du inte litar på.
\n- Amethyst-utvecklare kommer **aldrig** att be dig om din hemliga nyckel.
\n- **Behåll en säker säkerhetskopia av din hemliga nyckel för kontoåterställning. Vi rekommenderar att du använder en lösenordshanterare. </string>
<string name="secret_key_copied_to_clipboard">Hemlig nyckel (nsec) kopierad till urklipp</string>
<string name="copy_my_secret_key">Kopiera min hemliga nyckel</string>
<string name="biometric_authentication_failed">Autentisering misslyckades</string>
<string name="biometric_error">Error</string>
<string name=”badge_created_by">"Skapad av %1$s"</string>
<string name=”badge_award_image_for”>"Tilldelad Badge för %1$s"</string>
<string name=”new_badge_award_notif">Du har blivit tilldelad en ny Badge </string>
<string name="award_granted_to">Badge utmärkelse tilldelas</string>
<string name="copied_note_text_to_clipboard">Kopierade anteckningstext till urklipp</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Kopierade författarens @npub till urklipp</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Kopierade antecknings-ID (@note1) till urklipp</string>
<string name="select_text_dialog_top">Välj Text</string>
<string name="github" translatable="false">Github Gist med bevis</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon antecknings ID med bevis</string>
<string name="twitter" translatable="false">Twitter Status med bevis</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Kan inte dekryptera privat meddelande&gt;\n\nDu citerades i en privat/krypterad konversation mellan %1$s och %2$s."</string>
<string name=”account_switch_add_account_dialog_title">Lägg till nytt konto</string>
<string name="drawer_accounts">Konton</string>
<string name=”account_switch_select_account">Välj konto</string>
<string name=”account_switch_add_account_btn">Lägg till nytt konto</string>
<string name=”account_switch_active_account">Aktivt konto</string>
<string name=”account_switch_has_private_key">Har privat nyckel</string>
<string name=”account_switch_pubkey_only">Läs bara, ingen privat nyckel</string>
<string name="back">Back</string>
<string name="quick_action_select">Välj</string>
<string name="quick_action_share_browser_link">Dela webbläsarlänk</string>
<string name="quick_action_share">Dela</string>
<string name="quick_action_copy_user_id">Användar ID</string>
<string name="quick_action_copy_note_id">Antecknings ID</string>
<string name="quick_action_copy_text">Kopiera Text</string>
<string name="quick_action_delete">Radera</string>
<string name=”quick_action_unfollow">Sluta följa</string>
<string name="quick_action_follow">Följ</string>
<string name="quick_action_request_deletion_alert_title">Begär radering</string>
<string name="quick_action_request_deletion_alert_body">Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras.</string>
<string name="quick_action_block_dialog_btn">Blockera</string>
<string name="quick_action_delete_dialog_btn">Radera</string>
<string name="quick_action_block">Blockera</string>
<string name="quick_action_report">Rapportera</string>
<string name="quick_action_delete_button">Radera</string>
<string name=”quick_action_dont_show_again_button”>Visa inte igen</string>
<string name="report_dialog_spam">Spam eller scams</string>
<string name="report_dialog_profanity">Svordomar eller hatiskt beteende</string>
<string name="report_dialog_impersonation">Skadlig identitetsstöld</string>
<string name="report_dialog_nudity">Nakenhet eller grafiskt innehåll</string>
<string name="report_dialog_illegal">Olagligt beteende</string>
<string name="report_dialog_blocking_a_user">Blockering av en användare kommer att dölja deras innehåll i din app. Dina anteckningar är fortfarande offentliga, inklusive för personer du blockerar. Blockerade användare listas på skärmen Säkerhetsfilter.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blockera & Göm användare]]></string>
<string name="report_dialog_report_btn">Rapportera missbruk</string>
<string name="report_dialog_reminder_public">Alla rapporter som publiceras kommer att vara offentliga.</string>
<string name="report_dialog_additional_reason_placeholder">Ge eventuellt ytterligare sammanhang om din rapport...</string>
<string name="report_dialog_additional_reason_label">Ytterligare sammanhang</string>
<string name="report_dialog_select_reason_label">Anledning</string>
<string name=”report_dialog_select_reason_placeholder">Välj anledning…</string>
<string name=”report_dialog_post_report_btn">Skicka rapport</string>
<string name=”report_dialog_title">Blockera och rapportera</string>
<string name="block_only">Blockera</string>
<string name="bookmarks">Bokmärken</string>
<string name=”private_bookmarks">Privata Bokmärken</string>
<string name="public_bookmarks">Publika Bokmärken</string>
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
<string name=”add_to_public_bookmarks">Lägg till i Publika Bokmärken</string>
<string name="remove_from_private_bookmarks">Ta bort från Privata Bokmärken</string>
<string name="remove_from_public_bookmarks">Ta bort från Publika Bokmärken</string>
<string name="wallet_connect_service">Wallet Connect Service</string>
<string name="wallet_connect_service_explainer">Tillåter en Nostr Secret att betala zaps utan att lämna appen. Håll hemligheten säker och använd ett privat relä om möjligt</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Visa secret key</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex privat nyckel</string>
<string name="pledge_amount_in_sats">Pantbelopp i Sats</string>
<string name="post_poll">Dela Poll</string>
<string name="poll_heading_required">Obligatoriska fält:</string>
<string name="poll_zap_recipients">Zap mottagare</string>
<string name="poll_primary_description">Beskrivning av primär omröstning…</string>
<string name="poll_option_index">Alternativ %s</string>
<string name="poll_option_description">Beskrivning av omröstningsalternativ</string>
<string name="poll_heading_optional">Valfria fält:</string>
<string name=”poll_zap_value_min">Minsta Zap</string>
<string name=”poll_zap_value_max”>Maximal Zap</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name=”poll_closing_time">Avsluta efter</string>
<string name="poll_closing_time_days">dagar</string>
<string name="poll_is_closed">Omröstningen är stängd för nya röster</string>
<string name="poll_zap_amount">Zap belopp</string>
<string name="one_vote_per_user_on_atomic_votes">Endast en röst per användare tillåts i denna typ av omröstning</string>
<string name=”looking_for_event">"Letar efter Event %1$s"</string>
<string name="custom_zaps_add_a_message">Lägg till ett offentligt meddelande</string>
<string name="custom_zaps_add_a_message_private">Lägg till ett privat meddelande</string>
<string name="custom_zaps_add_a_message_nonzap">Lägg till ett fakturameddelande</string>
<string name="custom_zaps_add_a_message_example">Tack för allt ditt arbete!</string>
<string name="lightning_create_and_add_invoice">Skapa och lägg till</string>
<string name="poll_author_no_vote">Omröstningsskapare kan inte rösta i sina egna omröstningar.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">Bilden är densamma sedan inlägget</string>
<string name="hash_verification_failed">Bilden har ändrats. Användaren kanske inte har sett förändringen</string>
<string name=”content_description_add_image">Lägg till Bild</string>
<string name=”content_description_add_video">Lägg till Video</string>
<string name=”content_description_add_document">Lägg till Dokument</string>
<string name=”add_content">Lägg till Meddelande</string>
<string name="content_description">Beskrivning av innehållet</string>
<string name="content_description_example">En blå båt på en vit sandstrand vid solnedgången</string>
<string name="zap_type">Zap Typ</string>
<string name="zap_type_explainer">Zap Typ för alla alternativ</string>
<string name="zap_type_public">Publik</string>
<string name="zap_type_public_explainer">Alla kan se transaktionen och meddelandet</string>
<string name="zap_type_private">Privat</string>
<string name="zap_type_private_explainer">Avsändare och mottagare kan se varandra och läsa meddelandet</string>
<string name="zap_type_anonymous">Anonym</string>
<string name="zap_type_anonymous_explainer">Mottagaren och allmänheten vet inte vem som skickade betalningen</string>
<string name=”zap_type_nonzap">Ingen Zap</string>
<string name="zap_type_nonzap_explainer">Inga spår i Nostr, bara i Lightning</string>
<string name="file_server">Fil Server</string>
<string name="zap_forward_lnAddress">LnAdress or @Användare</string>
<string name="upload_server_imgur">imgur.com - betrodd</string>
<string name="upload_server_imgur_explainer">Imgur kan ändra filen</string>
<string name="upload_server_nostrimg">nostrimg.com - betrodd</string>
<string name=”upload_server_nostrimg_explainer">NostrImg kan ändra filen</string>
<string name="upload_server_nostrbuild">nostr.build - betrodd</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build kan ändra filen</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - betrodd</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan ändra filen</string>
<string name="upload_server_imgur_nip94">Verifierbar Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Kontrollerar om Imgur modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
<string name="upload_server_nostrimg_nip94">Verifierbar NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Kontrollerar om NostrImg modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
<string name="upload_server_nostrbuild_nip94">Verifierbar Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Kontrollerar om Nostr.build modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
<string name="upload_server_nostrfilesdev_nip94">Verifierbar Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Kontrollerar om Nostrfiles.dev modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
<string name=”upload_server_relays_nip95">Dina Reläer (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder</string>
<string name="connect_via_tor_short">Tor/Orbot Inställningar</string>
<string name="connect_via_tor">Anslut via dina Orbot-inställiningar</string>
<string name="do_you_really_want_to_disable_tor_title">Koppla från Orbot/Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Dina data kommer omedelbart att överföras till det vanliga nätverket</string>
<string name="yes">Ja</string>
<string name="no">Nej</string>
<string name="follow_list_selection">Följ lista</string>
<string name="follow_list_kind3follows">Alla följare</string>
<string name="follow_list_global">Global</string>
<string name="connect_through_your_orbot_setup_markdown">
## Anslut genom Tor med Orbot
\n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Starta Orbot
\n3. Kontrollera Socks-porten i Orbot. Standarden använder 9050
\n4. Ändra vid behov porten i Orbot
\n5. Konfigurera Socks-porten på den här skärmen
\n6. Tryck på knappen Aktivera för att använda Orbot som proxy</string>
<string name="orbot_socks_port">Orbot Socks Port</string>
<string name="invalid_port_number">Ogiltigt portnummer</string>
<string name="use_orbot">Använd Orbot</string>
<string name=”disconnect_from_your_orbot_setup">Koppla ifrån Tor/Orbot</string>
<string name="app_notification_channel_id" translatable="false">Standardkanal-ID</string>
<string name="app_notification_private_message" translatable=”false">Ny notifikation har kommit</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivataMeddelandID</string>
<string name="app_notification_dms_channel_name">Privata Meddelade</string>
<string name="app_notification_dms_channel_description">Meddelar dig när ett privat meddelande kommer</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
<string name="app_notification_zaps_channel_name">Zaps Mottaget</string>
<string name="app_notification_zaps_channel_description">Meddela dig när någon zappar dig</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">Från %1$s</string>
<string name="app_notification_zaps_channel_message_for">till %1$s</string>
<string name="reply_notify">Meddela: </string>
<string name="channel_list_join_conversation">Gå med i konversation</string>
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
<string name="channel_list_create_channel">Skapa</string>
<string name=”channel_list_join_channel">Gå med</string>
</resources>

View File

@@ -402,4 +402,6 @@
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
<string name="channel_list_create_channel">Create</string>
<string name="channel_list_join_channel">Join</string>
<string name="today">Today</string>
</resources>

View File

@@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -48,7 +49,7 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
var translatedTextState by remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
@@ -56,7 +57,7 @@ fun TranslatableRichTextViewer(
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope()
@@ -72,13 +73,17 @@ fun TranslatableRichTextViewer(
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
showOriginal = preference == task.result.sourceLang
}
translatedTextState.value = task.result
translatedTextState = task.result
}
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
val toBeViewed by remember {
derivedStateOf {
if (showOriginal) content else translatedTextState.result ?: content
}
}
Column() {
ExpandableRichTextViewer(
@@ -91,8 +96,8 @@ fun TranslatableRichTextViewer(
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
val target = translatedTextState.targetLang
val source = translatedTextState.sourceLang
if (source != null && target != null) {
if (source != target) {

View File

@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.service
import junit.framework.TestCase.assertEquals
import org.junit.Test
class Nip30Test {
@Test()
fun parseEmoji() {
val input = "Alex Gleason :soapbox:"
assertEquals(
listOf("Alex Gleason ", ":soapbox:", ""),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseEmojiInverted() {
val input = ":soapbox:Alex Gleason"
assertEquals(
listOf("", ":soapbox:", "Alex Gleason"),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseEmoji2() {
val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo"
assertEquals(
listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"),
NIP30Parser().buildArray(input)
)
println(NIP30Parser().buildArray(input).joinToString(","))
}
@Test()
fun parseEmoji3() {
val input = "hello vitor: how can I help:"
assertEquals(
listOf("hello vitor: how can I help:"),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseJapanese() {
val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)"
assertEquals(
listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"),
NIP30Parser().buildArray(input)
)
}
}

View File

@@ -7,6 +7,7 @@ buildscript {
room_version = "2.4.3"
accompanist_version = '0.30.0'
coil_version = '2.3.0'
vico_version = '1.6.5'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'