diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index 65829028f..77c10ab3f 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -228,8 +228,3 @@ jobs:
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
-
- - name: Drafts a description for the release
- uses: release-drafter/release-drafter@v5
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/README.md b/README.md
index 51a530e1d..9149eb6dd 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -40,7 +40,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
-- [ ] OpenTimestamps Attestations (NIP-03)
+- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
@@ -70,7 +70,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
+- [x] Git Stuff (NIP-34/Draft)
- [x] Sensitive Content (NIP-36)
+- [x] Note Edits (NIP-37/Draft)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
@@ -109,7 +111,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
-- [x] Audio Tracks (zapstr.live) (Kind:31337)
+- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
- [x] Hashtag Following and Custom Hashtags
@@ -118,6 +120,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] De-googled F-Droid flavor
- [x] Multiple Accounts
- [x] Markdown Support
+- [x] FHIR Payloads (kind:82)
+- [ ] Decentralized Wiki (kind:30818)
+- [ ] Embed events
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
@@ -135,16 +140,16 @@ Information shared on Nostr can be re-broadcasted to other servers and should be
# Development Overview
-This repository is split between Amethyst and Quartz:
+This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
-- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
+- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow objects to
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
-They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
+They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
are updated to receive more information about those particular Events.
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
@@ -236,12 +241,14 @@ dependencyResolutionManagement {
Add the dependency
```gradle
-implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
+implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
```
## Contributing
-[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are very welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
+Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
+
+[GitHub issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
diff --git a/app/build.gradle b/app/build.gradle
index 7f6f5be40..e266a4e6e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,7 +1,7 @@
plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
- id 'com.google.gms.google-services'
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.jetbrainsKotlinAndroid)
+ alias(libs.plugins.googleServices)
}
android {
@@ -12,9 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
- versionCode 358
- versionName "0.84.3"
- buildConfigField "String", "RELEASE_NOTES_ID", "\"4d5a05aec61d8798f30f76b2efab81b98d75a03f935fb82823a1080bd56473cd\""
+ versionCode 362
+ versionName "0.85.3"
+ buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -145,14 +145,13 @@ android {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
}
-
packagingOptions {
resources {
- excludes += '/META-INF/{AL2.0,LGPL2.1}'
+ excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
}
- exclude '**/libscrypt.dylib'
}
+
lint {
disable 'MissingTranslation'
}
@@ -165,75 +164,78 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
- implementation "androidx.core:core-ktx:$core_ktx_version"
- implementation 'androidx.activity:activity-compose:1.8.2'
- implementation "androidx.compose.ui:ui:$compose_ui_version"
- implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
+ implementation libs.androidx.core.ktx
+ implementation libs.androidx.activity.compose
+
+ implementation platform(libs.androidx.compose.bom)
+
+ implementation libs.androidx.ui
+ implementation libs.androidx.ui.graphics
+ implementation libs.androidx.ui.tooling.preview
// Needs this to open gallery / image upload
- implementation "androidx.fragment:fragment-ktx:$fragment_version"
+ implementation libs.androidx.fragment.ktx
// Navigation
- implementation "androidx.navigation:navigation-compose:$nav_version"
+ implementation libs.androidx.navigation.compose
// Observe Live data as State
- implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
+ implementation libs.androidx.runtime.livedata
// Material 3 Design
- implementation "androidx.compose.material3:material3:${material3_version}"
- implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
+ implementation libs.androidx.material3
+ implementation libs.androidx.material.icons
// Adaptive Layout / Two Pane
- implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
- implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
-
+ implementation libs.androidx.material3.windowSize
+ implementation libs.accompanist.adaptive
// Lifecycle
- implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
- implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
- implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
- implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
+ implementation libs.androidx.lifecycle.runtime.ktx
+ implementation libs.androidx.lifecycle.runtime.compose
+ implementation libs.androidx.lifecycle.viewmodel.compose
+ implementation libs.androidx.lifecycle.livedata.ktx
// Zoomable images
- implementation 'net.engawapg.lib:zoomable:1.6.0'
+ implementation libs.zoomable
// Biometrics
- implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
+ implementation libs.androidx.biometric.ktx
// Websockets API
- implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
+ implementation libs.okhttp
// HTML Parsing for Link Preview
- implementation 'org.jsoup:jsoup:1.17.2'
+ implementation libs.jsoup
// Encrypted Key Storage
- implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
+ implementation libs.androidx.security.crypto.ktx
// view videos
- implementation "androidx.media3:media3-exoplayer:$media3_version"
- implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
- implementation "androidx.media3:media3-ui:$media3_version"
- implementation "androidx.media3:media3-session:$media3_version"
+ implementation libs.androidx.media3.exoplayer
+ implementation libs.androidx.media3.exoplayer.hls
+ implementation libs.androidx.media3.ui
+ implementation libs.androidx.media3.session
// important for proxy / tor
- implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
+ implementation libs.androidx.media3.datasource.okhttp
// Load images from the web.
- implementation "io.coil-kt:coil-compose:$coil_version"
+ implementation libs.coil.compose
// view gifs
- implementation "io.coil-kt:coil-gif:$coil_version"
+ implementation libs.coil.gif
// view svgs
- implementation "io.coil-kt:coil-svg:$coil_version"
+ implementation libs.coil.svg
// create blurhash
- implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
+ implementation libs.trbl.blurhash
// Permission to upload pictures:
- implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
+ implementation libs.accompanist.permissions
// For QR generation
- implementation 'com.google.zxing:core:3.5.3'
- implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+ implementation libs.zxing
+ implementation libs.zxing.embedded
// Markdown
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
@@ -241,51 +243,50 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
- implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
- implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
- implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
+ implementation libs.markdown.ui
+ implementation libs.markdown.ui.material3
+ implementation libs.markdown.commonmark
// Language picker and Theme chooser
- implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation libs.androidx.appcompat
// Local model for language identification
- playImplementation 'com.google.mlkit:language-id:17.0.4'
+ playImplementation libs.google.mlkit.language.id
// Google services model the translate text
- playImplementation 'com.google.mlkit:translate:17.0.2'
+ playImplementation libs.google.mlkit.translate
// PushNotifications
- playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
- playImplementation 'com.google.firebase:firebase-messaging-ktx'
+ playImplementation platform(libs.firebase.bom)
+ playImplementation libs.firebase.messaging
//PushNotifications(FDroid)
- fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
+ fdroidImplementation libs.unifiedpush
// 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}"
+ implementation libs.vico.charts.core
+ implementation libs.vico.charts.compose
+ implementation libs.vico.charts.views
+ implementation libs.vico.charts.m3
// GeoHash
- implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
+ implementation libs.drfonfon.geohash
// Waveform visualizer
- implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
+ implementation libs.audiowaveform
// Video compression lib
- implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
+ implementation libs.abedElazizShe.image.compressor
// Image compression lib
- implementation 'id.zelory:compressor:3.0.1'
+ implementation libs.zelory.video.compressor
- testImplementation 'junit:junit:4.13.2'
- testImplementation 'io.mockk:mockk:1.13.9'
- androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
- androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
- debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
- debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
+ testImplementation libs.junit
+ testImplementation libs.mockk
+ androidTestImplementation libs.androidx.junit
+ androidTestImplementation libs.androidx.junit.ktx
+ androidTestImplementation libs.androidx.espresso.core
+ debugImplementation libs.androidx.ui.tooling
+ debugImplementation libs.androidx.ui.test.manifest
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
index fe225dcaf..b5f4b216b 100644
--- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
+++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
@@ -34,6 +34,7 @@ fun TranslatableRichTextViewer(
modifier: Modifier = Modifier,
tags: ImmutableListOfLists
,
backgroundColor: MutableState,
+ id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
@@ -42,6 +43,7 @@ fun TranslatableRichTextViewer(
modifier,
tags,
backgroundColor,
+ id,
accountViewModel,
nav,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index c7421c35f..039090ac7 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GeneralListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
+import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -89,6 +90,7 @@ import com.vitorpamplona.quartz.events.Response
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -1348,6 +1350,63 @@ class Account(
}
}
+ fun sendGitReply(
+ message: String,
+ replyTo: List?,
+ mentions: List?,
+ repository: ATag?,
+ zapReceiver: List? = null,
+ wantsToMarkAsSensitive: Boolean,
+ zapRaiserAmount: Long? = null,
+ replyingTo: String?,
+ root: String?,
+ directMentions: Set,
+ forkedFrom: Event?,
+ relayList: List? = null,
+ geohash: String? = null,
+ nip94attachments: List? = null,
+ ) {
+ if (!isWriteable()) return
+
+ val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+ val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList())
+
+ GitReplyEvent.create(
+ msg = message,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ addresses = addresses,
+ extraTags = null,
+ zapReceiver = zapReceiver,
+ markAsSensitive = wantsToMarkAsSensitive,
+ zapRaiserAmount = zapRaiserAmount,
+ replyingTo = replyingTo,
+ root = root,
+ directMentions = directMentions,
+ geohash = geohash,
+ nip94attachments = nip94attachments,
+ forkedFrom = forkedFrom,
+ signer = signer,
+ ) {
+ Client.send(it, relayList = relayList)
+ LocalCache.justConsume(it, null)
+
+ // broadcast replied notes
+ replyingTo?.let {
+ LocalCache.getNoteIfExists(replyingTo)?.event?.let {
+ Client.send(it, relayList = relayList)
+ }
+ }
+ replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
+ addresses?.forEach {
+ LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
+ Client.send(it, relayList = relayList)
+ }
+ }
+ }
+ }
+
fun sendPost(
message: String,
replyTo: List?,
@@ -1405,6 +1464,29 @@ class Account(
}
}
+ fun sendEdit(
+ message: String,
+ originalNote: Note,
+ notify: HexKey?,
+ summary: String? = null,
+ relayList: List? = null,
+ ) {
+ if (!isWriteable()) return
+
+ val idHex = originalNote.event?.id() ?: return
+
+ TextNoteModificationEvent.create(
+ content = message,
+ eventId = idHex,
+ notify = notify,
+ summary = summary,
+ signer = signer,
+ ) {
+ LocalCache.justConsume(it, null)
+ Client.send(it, relayList = relayList)
+ }
+ }
+
fun sendPoll(
message: String,
replyTo: List?,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index f366531a5..aa25df2be 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
+import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -98,6 +99,7 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -449,14 +451,21 @@ object LocalCache {
return
}
+ val repository = event.repository()?.toTag()
+
val replyTo =
event
.tagsWithoutCitations()
- .filter { it != event.repository()?.toTag() }
+ .filter { it != repository }
.mapNotNull { checkGetOrCreateNote(it) }
+ // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
+
note.loadEvent(event, author, replyTo)
+ // Counts the replies
+ replyTo.forEach { it.addReply(note) }
+
refreshObservers(note)
}
@@ -745,7 +754,9 @@ object LocalCache {
if (version.event == null) {
version.loadEvent(event, author, emptyList())
-
+ if (version.liveSet != null) {
+ updateListCache()
+ }
version.liveSet?.innerOts?.invalidateData()
}
@@ -1384,6 +1395,37 @@ object LocalCache {
refreshObservers(note)
}
+ fun consume(
+ event: TextNoteModificationEvent,
+ relay: Relay?,
+ ) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ event.editedNote()?.let {
+ checkGetOrCreateNote(it)?.let { editedNote ->
+ modificationCache.remove(editedNote.idHex)
+ // must update list of Notes to quickly update the user.
+ if (editedNote.liveSet != null) {
+ updateListCache()
+ }
+ editedNote.liveSet?.innerModifications?.invalidateData()
+ }
+ }
+
+ refreshObservers(note)
+ }
+
fun consume(
event: HighlightEvent,
relay: Relay?,
@@ -1613,6 +1655,8 @@ object LocalCache {
it.event !is CommunityPostApprovalEvent &&
it.event !is ReactionEvent &&
it.event !is GiftWrapEvent &&
+ it.event !is SealedGossipEvent &&
+ it.event !is OtsEvent &&
it.event !is LnZapEvent &&
it.event !is LnZapRequestEvent
) &&
@@ -1695,6 +1739,35 @@ object LocalCache {
return minTime
}
+ val modificationCache = LruCache>(20)
+
+ fun cachedModificationEventsForNote(note: Note): List? {
+ return modificationCache[note.idHex]
+ }
+
+ suspend fun findLatestModificationForNote(note: Note): List {
+ checkNotInMainThread()
+
+ val originalAuthor = note.author?.pubkeyHex ?: return emptyList()
+
+ modificationCache[note.idHex]?.let {
+ return it
+ }
+
+ val time = TimeUtils.now()
+
+ val newNotes =
+ noteListCache.filter { item ->
+ val noteEvent = item.event
+
+ noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
+ }.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
+
+ modificationCache.put(note.idHex, newNotes)
+
+ return newNotes
+ }
+
fun cleanObservers() {
noteListCache.forEach { it.clearLive() }
@@ -2048,6 +2121,7 @@ object LocalCache {
}
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
+ is TextNoteModificationEvent -> consume(event, relay)
is VideoHorizontalEvent -> consume(event, relay)
is VideoVerticalEvent -> consume(event, relay)
is WikiNoteEvent -> consume(event, relay)
@@ -2073,8 +2147,8 @@ class LocalCacheLiveData {
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) {
bundledNewNotes ->
- _newEventBundles.emit(bundledNewNotes)
LocalCache.updateListCache()
+ _newEventBundles.emit(bundledNewNotes)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt
similarity index 75%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt
index b782a1c19..6bf60d156 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt
@@ -18,10 +18,16 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.note
+package com.vitorpamplona.amethyst.model
+import android.util.Log
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableMap
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@@ -110,6 +116,11 @@ class Reference(
var reference: String? = null,
)
+data class FhirElementDatabase(
+ var baseResource: Resource? = null,
+ var localDb: ImmutableMap = persistentMapOf(),
+)
+
fun findReferenceInDb(
it: String,
db: Map,
@@ -121,3 +132,30 @@ fun findReferenceInDb(
db.get(it.removePrefix("#"))
}
}
+
+fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
+ val mapper =
+ jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ return try {
+ val resource = mapper.readValue(json, Resource::class.java)
+
+ val db =
+ when (resource) {
+ is Bundle -> {
+ resource.entry.associateBy { it.id }.toImmutableMap()
+ }
+ else -> {
+ persistentMapOf(resource.id to resource)
+ }
+ }
+
+ FhirElementDatabase(
+ localDb = db,
+ baseResource = resource,
+ )
+ } catch (e: Exception) {
+ Log.e("RenderEyeGlassesPrescription", "Parser error", e)
+ null
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
index 73526c322..77f380b3c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
@@ -948,6 +948,7 @@ class NoteLiveSet(u: Note) {
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
+ val innerModifications = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
@@ -1001,7 +1002,9 @@ class NoteLiveSet(u: Note) {
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
- boostCount.hasObservers()
+ boostCount.hasObservers() ||
+ innerOts.hasObservers() ||
+ innerModifications.hasObservers()
}
fun destroy() {
@@ -1013,6 +1016,7 @@ class NoteLiveSet(u: Note) {
innerRelays.destroy()
innerZaps.destroy()
innerOts.destroy()
+ innerModifications.destroy()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
index 30ded640a..52ccbacd5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
@@ -310,6 +310,7 @@ class User(val pubkeyHex: String) {
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
+ info?.cleanBlankNames()
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt
index 25e6c6050..0598ee30c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
- val richTextCache = LruCache(200)
+ val richTextCache = LruCache(50)
fun parseText(
content: String,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt
index ecd1918f7..ce3b280c6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
+import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -42,6 +43,24 @@ data class CashuToken(
val proofs: JsonNode,
)
+object CachedCashuProcessor {
+ val cashuCache = LruCache>(20)
+
+ fun cached(token: String): GenericLoadable {
+ return cashuCache[token] ?: GenericLoadable.Loading()
+ }
+
+ fun parse(token: String): GenericLoadable {
+ if (cashuCache[token] !is GenericLoadable.Loaded) {
+ val newCachuData = CashuProcessor().parse(token)
+
+ cashuCache.put(token, newCachuData)
+ }
+
+ return cashuCache[token]
+ }
+}
+
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable {
checkNotInMainThread()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt
index cd741c52a..cf02079bf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt
@@ -27,6 +27,7 @@ import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.HandlerThread
+import android.util.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
@@ -92,7 +93,33 @@ class LocationUtil(context: Context) {
}
}
-class ReverseGeoLocationUtil {
+object CachedGeoLocations {
+ val locationNames = LruCache(20)
+
+ fun cached(geoHashStr: String): String? {
+ return locationNames[geoHashStr]
+ }
+
+ suspend fun geoLocate(
+ geoHashStr: String,
+ location: Location,
+ context: Context,
+ ): String? {
+ locationNames[geoHashStr]?.let {
+ return it
+ }
+
+ val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
+
+ if (name != null) {
+ locationNames.put(geoHashStr, name)
+ }
+
+ return name
+ }
+}
+
+private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
index cedf0bd4f..0e49e30bf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
@@ -35,6 +35,9 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
+import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
+import com.vitorpamplona.quartz.events.CalendarRSVPEvent
+import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
@@ -42,6 +45,10 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
+import com.vitorpamplona.quartz.events.GitIssueEvent
+import com.vitorpamplona.quartz.events.GitPatchEvent
+import com.vitorpamplona.quartz.events.GitReplyEvent
+import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -120,24 +127,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
- fun createAccountAcceptedAwardsFilter(): TypedFilter {
+ fun createAccountSettingsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
- kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
- authors = listOf(account.userProfile().pubkeyHex),
- limit = 10,
- ),
- )
- }
-
- fun createAccountBookmarkListFilter(): TypedFilter {
- return TypedFilter(
- types = COMMON_FEED_TYPES,
- filter =
- JsonFilter(
- kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND),
+ kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
@@ -204,6 +199,36 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
+ fun createNotificationFilter2(): TypedFilter {
+ val since =
+ latestEOSEs.users[account.userProfile()]
+ ?.followList
+ ?.get(account.defaultNotificationFollowList.value)
+ ?.relayList
+ ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
+ ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
+
+ return TypedFilter(
+ types = COMMON_FEED_TYPES,
+ filter =
+ JsonFilter(
+ kinds =
+ listOf(
+ GitReplyEvent.KIND,
+ GitIssueEvent.KIND,
+ GitPatchEvent.KIND,
+ HighlightEvent.KIND,
+ CalendarDateSlotEvent.KIND,
+ CalendarTimeSlotEvent.KIND,
+ CalendarRSVPEvent.KIND,
+ ),
+ tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
+ limit = 400,
+ since = since,
+ ),
+ )
+ }
+
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
@@ -297,10 +322,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
+ createNotificationFilter2(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
- createAccountAcceptedAwardsFilter(),
- createAccountBookmarkListFilter(),
+ createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
@@ -312,7 +337,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
- createAccountBookmarkListFilter(),
+ createAccountSettingsFilter(),
)
.ifEmpty { null }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
index 47cb0af5d..0a1623e01 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
@@ -221,7 +221,7 @@ abstract class NostrDataSource(val debugName: String) {
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
- val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
+ val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters }
changingFilters.getAndSet(true)
@@ -245,7 +245,7 @@ abstract class NostrDataSource(val debugName: String) {
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
- if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
+ if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
Client.close(updatedSubscription.id)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
@@ -265,7 +265,7 @@ abstract class NostrDataSource(val debugName: String) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
- if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
+ if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
index e29566892..659314695 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
+import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.OtsEvent
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf()
@@ -136,6 +138,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
+ TextNoteModificationEvent.KIND,
+ GitReplyEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt
new file mode 100644
index 000000000..67a7edcc4
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.lnurl
+
+import android.util.LruCache
+import androidx.compose.runtime.Stable
+import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
+import kotlinx.coroutines.CancellationException
+import java.text.NumberFormat
+
+@Stable
+data class InvoiceAmount(val invoice: String, val amount: String?)
+
+object CachedLnInvoiceParser {
+ val lnInvoicesCache = LruCache(20)
+
+ fun cached(lnurl: String): InvoiceAmount? {
+ return lnInvoicesCache[lnurl]
+ }
+
+ fun parse(lnbcWord: String): InvoiceAmount? {
+ val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
+ if (myInvoice != null) {
+ val myInvoiceAmount =
+ try {
+ NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ e.printStackTrace()
+ null
+ }
+
+ val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
+
+ lnInvoicesCache.put(lnbcWord, lnInvoice)
+
+ return lnInvoice
+ }
+
+ return null
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt
index dc7d66d9b..eac7b0f8f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt
@@ -37,26 +37,26 @@ class JsonFilter(
val filter =
factory.objectNode().apply {
ids?.run {
- put(
+ replace(
"ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
)
}
authors?.run {
- put(
+ replace(
"authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
)
}
kinds?.run {
- put(
+ replace(
"kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
)
}
tags?.run {
entries.forEach { kv ->
- put(
+ replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt
index eefe41b99..ebd0ce388 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relays
-import com.fasterxml.jackson.databind.JsonNode
-import com.vitorpamplona.quartz.events.Event
import java.util.UUID
data class Subscription(
@@ -37,23 +35,36 @@ data class Subscription(
onEOSE?.let { it(time, relay) }
}
- fun toJson(): String {
- return Event.mapper.writeValueAsString(toJsonObject())
- }
+ fun hasChangedFiltersFrom(otherFilters: List?): Boolean {
+ if (typedFilters == null && otherFilters == null) return false
+ if (typedFilters?.size != otherFilters?.size) return true
- fun toJsonObject(): JsonNode {
- val factory = Event.mapper.nodeFactory
+ typedFilters?.forEachIndexed { index, typedFilter ->
+ val otherFilter = otherFilters?.getOrNull(index) ?: return true
- return factory.objectNode().apply {
- put("id", id)
- typedFilters?.also { filters ->
- put(
- "typedFilters",
- factory.arrayNode(filters.size).apply {
- filters.forEach { filter -> add(filter.toJsonObject()) }
- },
- )
+ // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
+ // fast check
+ if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size ||
+ typedFilter.filter.ids?.size != otherFilter.filter.ids?.size ||
+ typedFilter.filter.tags?.size != otherFilter.filter.tags?.size ||
+ typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size ||
+ typedFilter.filter.limit != otherFilter.filter.limit ||
+ typedFilter.filter.search?.length != otherFilter.filter.search?.length ||
+ typedFilter.filter.until != otherFilter.filter.until
+ ) {
+ return true
+ }
+
+ // deep check
+ if (typedFilter.filter.ids != otherFilter.filter.ids ||
+ typedFilter.filter.authors != otherFilter.filter.authors ||
+ typedFilter.filter.tags != otherFilter.filter.tags ||
+ typedFilter.filter.kinds != otherFilter.filter.kinds ||
+ typedFilter.filter.search != otherFilter.filter.search
+ ) {
+ return true
}
}
+ return false
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt
index 84ef11d26..4817e7b30 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt
@@ -20,73 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relays
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.node.ArrayNode
-import com.vitorpamplona.quartz.events.Event
-
class TypedFilter(
val types: Set,
val filter: JsonFilter,
-) {
- fun toJson(): String {
- return Event.mapper.writeValueAsString(toJsonObject())
- }
-
- fun toJsonObject(): JsonNode {
- val factory = Event.mapper.nodeFactory
-
- return factory.objectNode().apply {
- put("types", typesToJson(types))
- put("filter", filterToJson(filter))
- }
- }
-
- fun typesToJson(types: Set): ArrayNode {
- val factory = Event.mapper.nodeFactory
- return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } }
- }
-
- fun filterToJson(filter: JsonFilter): JsonNode {
- val factory = Event.mapper.nodeFactory
- return factory.objectNode().apply {
- filter.ids?.run {
- put(
- "ids",
- factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
- )
- }
- filter.authors?.run {
- put(
- "authors",
- factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
- )
- }
- filter.kinds?.run {
- put(
- "kinds",
- factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
- )
- }
- filter.tags?.run {
- entries.forEach { kv ->
- put(
- "#${kv.key}",
- factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
- )
- }
- }
- /*
- Does not include since in the json comparison
- filter.since?.run {
- val jsonObjectSince = JsonObject()
- entries.forEach { sincePairs ->
- jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
- }
- jsonObject.add("since", jsonObjectSince)
- }*/
- filter.until?.run { put("until", filter.until) }
- filter.limit?.run { put("limit", filter.limit) }
- filter.search?.run { put("search", filter.search) }
- }
- }
-}
+)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt
new file mode 100644
index 000000000..70d6db904
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt
@@ -0,0 +1,555 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions
+
+import android.widget.Toast
+import androidx.compose.foundation.border
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CurrencyBitcoin
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.RichTextParser
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
+import com.vitorpamplona.amethyst.ui.components.BechLink
+import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
+import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
+import com.vitorpamplona.amethyst.ui.components.VideoView
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
+import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditPostView(
+ onClose: () -> Unit,
+ edit: Note,
+ versionLookingAt: Note?,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val postViewModel: EditPostViewModel = viewModel()
+ postViewModel.prepare(edit, versionLookingAt, accountViewModel)
+
+ val context = LocalContext.current
+
+ val scrollState = rememberScrollState()
+ val scope = rememberCoroutineScope()
+ var showRelaysDialog by remember { mutableStateOf(false) }
+ var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
+
+ LaunchedEffect(Unit) {
+ postViewModel.load(edit, versionLookingAt, accountViewModel)
+
+ launch(Dispatchers.IO) {
+ postViewModel.imageUploadingError.collect { error ->
+ withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
+ }
+ }
+ }
+
+ DisposableEffect(Unit) {
+ NostrSearchEventOrUserDataSource.start()
+
+ onDispose {
+ NostrSearchEventOrUserDataSource.clear()
+ NostrSearchEventOrUserDataSource.stop()
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { onClose() },
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnClickOutside = false,
+ decorFitsSystemWindows = false,
+ ),
+ ) {
+ if (showRelaysDialog) {
+ RelaySelectionDialog(
+ preSelectedList = relayList,
+ onClose = { showRelaysDialog = false },
+ onPost = { relayList = it },
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(modifier = StdHorzSpacer)
+
+ Box {
+ IconButton(
+ modifier = Modifier.align(Alignment.Center),
+ onClick = { showRelaysDialog = true },
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.relays),
+ contentDescription = stringResource(id = R.string.relay_list_selector),
+ modifier = Modifier.height(25.dp),
+ tint = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+ }
+ PostButton(
+ onPost = {
+ postViewModel.sendPost(relayList = relayList)
+ scope.launch {
+ delay(100)
+ onClose()
+ }
+ },
+ isActive = postViewModel.canPost(),
+ )
+ }
+ },
+ navigationIcon = {
+ Row {
+ Spacer(modifier = StdHorzSpacer)
+ CloseButton(
+ onPress = {
+ postViewModel.cancel()
+ scope.launch {
+ delay(100)
+ onClose()
+ }
+ },
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { pad ->
+ Surface(
+ modifier =
+ Modifier
+ .padding(
+ start = Size10dp,
+ top = pad.calculateTopPadding(),
+ end = Size10dp,
+ bottom = pad.calculateBottomPadding(),
+ )
+ .fillMaxSize(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .imePadding()
+ .weight(1f),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ ) {
+ postViewModel.editedFromNote?.let {
+ Row(Modifier.heightIn(max = 200.dp)) {
+ NoteCompose(
+ baseNote = it,
+ makeItShort = true,
+ unPackReply = false,
+ isQuotedNote = true,
+ modifier = MaterialTheme.colorScheme.replyModifier,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ Spacer(modifier = StdVertSpacer)
+ }
+ }
+
+ MessageField(postViewModel)
+
+ val myUrlPreview = postViewModel.urlPreview
+ if (myUrlPreview != null) {
+ Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
+ if (RichTextParser.isValidURL(myUrlPreview)) {
+ if (RichTextParser.isImageUrl(myUrlPreview)) {
+ AsyncImage(
+ model = myUrlPreview,
+ contentDescription = myUrlPreview,
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ),
+ )
+ } else if (RichTextParser.isVideoUrl(myUrlPreview)) {
+ VideoView(
+ myUrlPreview,
+ roundedCorner = true,
+ accountViewModel = accountViewModel,
+ )
+ } else {
+ LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
+ }
+ } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
+ val bgColor = MaterialTheme.colorScheme.background
+ val backgroundColor = remember { mutableStateOf(bgColor) }
+
+ BechLink(
+ myUrlPreview,
+ true,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
+ } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
+ LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
+ }
+ }
+ }
+
+ val url = postViewModel.contentToAddUrl
+ if (url != null) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
+ ) {
+ ImageVideoDescription(
+ url,
+ accountViewModel.account.defaultFileServer,
+ onAdd = { alt, server, sensitiveContent ->
+ postViewModel.upload(url, alt, sensitiveContent, false, server, context)
+ if (!server.isNip95) {
+ accountViewModel.account.changeDefaultFileServer(server.server)
+ }
+ },
+ onCancel = { postViewModel.contentToAddUrl = null },
+ onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+
+ val user = postViewModel.account?.userProfile()
+ val lud16 = user?.info?.lnAddress()
+
+ if (lud16 != null && postViewModel.wantsInvoice) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ InvoiceRequest(
+ lud16,
+ user.pubkeyHex,
+ accountViewModel.account,
+ stringResource(id = R.string.lightning_invoice),
+ stringResource(id = R.string.lightning_create_and_add_invoice),
+ onSuccess = {
+ postViewModel.message =
+ TextFieldValue(postViewModel.message.text + "\n\n" + it)
+ postViewModel.wantsInvoice = false
+ },
+ onClose = { postViewModel.wantsInvoice = false },
+ onError = { title, message -> accountViewModel.toast(title, message) },
+ )
+ }
+ }
+ }
+
+ /*
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp),
+ ) {
+ Column {
+ Text(
+ text = stringResource(R.string.message_to_author),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.W500,
+ )
+
+ HorizontalDivider(thickness = DividerThickness)
+
+ MyTextField(
+ value = postViewModel.subject,
+ onValueChange = { postViewModel.updateSubject(it) },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(
+ text = stringResource(R.string.message_to_author_placeholder),
+ color = MaterialTheme.colorScheme.placeholderText,
+ )
+ },
+ visualTransformation =
+ UrlUserTagTransformation(
+ MaterialTheme.colorScheme.primary,
+ ),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent,
+ ),
+ )
+ }
+ }*/
+ }
+ }
+
+ ShowUserSuggestionListForEdit(
+ postViewModel,
+ accountViewModel,
+ modifier = Modifier.heightIn(0.dp, 300.dp),
+ )
+
+ BottomRowActions(postViewModel)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ShowUserSuggestionListForEdit(
+ editPostViewModel: EditPostViewModel,
+ accountViewModel: AccountViewModel,
+ modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
+) {
+ val userSuggestions = editPostViewModel.userSuggestions
+ if (userSuggestions.isNotEmpty()) {
+ LazyColumn(
+ contentPadding =
+ PaddingValues(
+ top = 10.dp,
+ ),
+ modifier = modifier,
+ ) {
+ itemsIndexed(
+ userSuggestions,
+ key = { _, item -> item.pubkeyHex },
+ ) { _, item ->
+ UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BottomRowActions(postViewModel: EditPostViewModel) {
+ val scrollState = rememberScrollState()
+
+ Row(
+ modifier =
+ Modifier
+ .horizontalScroll(scrollState)
+ .fillMaxWidth()
+ .height(50.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ UploadFromGallery(
+ isUploading = postViewModel.isUploadingImage,
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier,
+ ) {
+ postViewModel.selectImage(it)
+ }
+
+ if (postViewModel.canAddInvoice) {
+ AddLnInvoiceButton(postViewModel.wantsInvoice) {
+ postViewModel.wantsInvoice = !postViewModel.wantsInvoice
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageField(postViewModel: EditPostViewModel) {
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(Unit) {
+ launch {
+ delay(200)
+ focusRequester.requestFocus()
+ }
+ }
+
+ OutlinedTextField(
+ value = postViewModel.message,
+ onValueChange = { postViewModel.updateMessage(it) },
+ keyboardOptions =
+ KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.Sentences,
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(8.dp),
+ )
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ keyboardController?.show()
+ }
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.what_s_on_your_mind),
+ color = MaterialTheme.colorScheme.placeholderText,
+ )
+ },
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent,
+ ),
+ visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
+ textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
+ )
+}
+
+@Composable
+private fun AddLnInvoiceButton(
+ isLnInvoiceActive: Boolean,
+ onClick: () -> Unit,
+) {
+ IconButton(
+ onClick = { onClick() },
+ ) {
+ if (!isLnInvoiceActive) {
+ Icon(
+ imageVector = Icons.Default.CurrencyBitcoin,
+ contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onBackground,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.CurrencyBitcoin,
+ contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
+ modifier = Modifier.size(20.dp),
+ tint = BitcoinOrange,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt
new file mode 100644
index 000000000..b2439727d
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt
@@ -0,0 +1,390 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vitorpamplona.amethyst.commons.RichTextParser
+import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.service.FileHeader
+import com.vitorpamplona.amethyst.service.Nip96Uploader
+import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
+import com.vitorpamplona.amethyst.service.relays.Relay
+import com.vitorpamplona.amethyst.ui.components.MediaCompressor
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.events.FileHeaderEvent
+import com.vitorpamplona.quartz.events.FileStorageEvent
+import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+
+@Stable
+open class EditPostViewModel() : ViewModel() {
+ var accountViewModel: AccountViewModel? = null
+ var account: Account? = null
+
+ var editedFromNote: Note? = null
+
+ var subject by mutableStateOf(TextFieldValue(""))
+
+ var nip94attachments by mutableStateOf>(emptyList())
+ var nip95attachments by
+ mutableStateOf>>(emptyList())
+
+ var message by mutableStateOf(TextFieldValue(""))
+ var urlPreview by mutableStateOf(null)
+ var isUploadingImage by mutableStateOf(false)
+ val imageUploadingError =
+ MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+
+ var userSuggestions by mutableStateOf>(emptyList())
+ var userSuggestionAnchor: TextRange? = null
+ var userSuggestionsMainMessage: UserSuggestionAnchor? = null
+
+ // Images and Videos
+ var contentToAddUrl by mutableStateOf(null)
+
+ // Invoices
+ var canAddInvoice by mutableStateOf(false)
+ var wantsInvoice by mutableStateOf(false)
+
+ open fun prepare(
+ edit: Note,
+ versionLookingAt: Note?,
+ accountViewModel: AccountViewModel,
+ ) {
+ this.accountViewModel = accountViewModel
+ this.account = accountViewModel.account
+ this.editedFromNote = edit
+ }
+
+ open fun load(
+ edit: Note,
+ versionLookingAt: Note?,
+ accountViewModel: AccountViewModel,
+ ) {
+ this.accountViewModel = accountViewModel
+ this.account = accountViewModel.account
+
+ canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
+ contentToAddUrl = null
+
+ message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
+ urlPreview = findUrlInMessage()
+
+ editedFromNote = edit
+ }
+
+ fun sendPost(relayList: List? = null) {
+ viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
+ }
+
+ suspend fun innerSendPost(relayList: List? = null) {
+ if (accountViewModel == null) {
+ cancel()
+ return
+ }
+
+ nip95attachments.forEach {
+ account?.sendNip95(it.first, it.second, relayList)
+ }
+
+ val notify =
+ if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) {
+ null
+ } else {
+ // notifies if it is not the logged in user
+ editedFromNote?.author?.pubkeyHex
+ }
+
+ account?.sendEdit(
+ message = message.text,
+ originalNote = editedFromNote!!,
+ notify = notify,
+ summary = subject.text.ifBlank { null },
+ relayList = relayList,
+ )
+
+ cancel()
+ }
+
+ open fun updateSubject(it: TextFieldValue) {
+ subject = it
+ }
+
+ fun upload(
+ galleryUri: Uri,
+ alt: String?,
+ sensitiveContent: Boolean,
+ isPrivate: Boolean = false,
+ server: ServerOption,
+ context: Context,
+ ) {
+ isUploadingImage = true
+ contentToAddUrl = null
+
+ val contentResolver = context.contentResolver
+ val contentType = contentResolver.getType(galleryUri)
+
+ viewModelScope.launch(Dispatchers.IO) {
+ MediaCompressor()
+ .compress(
+ galleryUri,
+ contentType,
+ context.applicationContext,
+ onReady = { fileUri, contentType, size ->
+ if (server.isNip95) {
+ contentResolver.openInputStream(fileUri)?.use {
+ createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
+ }
+ } else {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val result =
+ Nip96Uploader(account)
+ .uploadImage(
+ uri = fileUri,
+ contentType = contentType,
+ size = size,
+ alt = alt,
+ sensitiveContent = if (sensitiveContent) "" else null,
+ server = server.server,
+ contentResolver = contentResolver,
+ onProgress = {},
+ )
+
+ createNIP94Record(
+ uploadingResult = result,
+ localContentType = contentType,
+ alt = alt,
+ sensitiveContent = sensitiveContent,
+ )
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e(
+ "ImageUploader",
+ "Failed to upload ${e.message}",
+ e,
+ )
+ isUploadingImage = false
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload: ${e.message}")
+ }
+ }
+ }
+ }
+ },
+ onError = {
+ isUploadingImage = false
+ viewModelScope.launch { imageUploadingError.emit(it) }
+ },
+ )
+ }
+ }
+
+ open fun cancel() {
+ message = TextFieldValue("")
+ subject = TextFieldValue("")
+
+ editedFromNote = null
+
+ contentToAddUrl = null
+ urlPreview = null
+ isUploadingImage = false
+
+ wantsInvoice = false
+
+ userSuggestions = emptyList()
+ userSuggestionAnchor = null
+ userSuggestionsMainMessage = null
+
+ NostrSearchEventOrUserDataSource.clear()
+ }
+
+ open fun findUrlInMessage(): String? {
+ return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
+ paragraph.split(' ').firstOrNull { word: String ->
+ RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
+ }
+ }
+ }
+
+ open fun updateMessage(it: TextFieldValue) {
+ message = it
+ urlPreview = findUrlInMessage()
+
+ if (it.selection.collapsed) {
+ val lastWord =
+ it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
+ userSuggestionAnchor = it.selection
+ userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
+ if (lastWord.startsWith("@") && lastWord.length > 2) {
+ NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
+ viewModelScope.launch(Dispatchers.IO) {
+ userSuggestions =
+ LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
+ .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
+ .reversed()
+ }
+ } else {
+ NostrSearchEventOrUserDataSource.clear()
+ userSuggestions = emptyList()
+ }
+ }
+ }
+
+ open fun autocompleteWithUser(item: User) {
+ userSuggestionAnchor?.let {
+ if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
+ val lastWord =
+ message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
+ val lastWordStart = it.end - lastWord.length
+ val wordToInsert = "@${item.pubkeyNpub()}"
+
+ message =
+ TextFieldValue(
+ message.text.replaceRange(lastWordStart, it.end, wordToInsert),
+ TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
+ )
+ }
+
+ userSuggestionAnchor = null
+ userSuggestionsMainMessage = null
+ userSuggestions = emptyList()
+ }
+ }
+
+ fun canPost(): Boolean {
+ return message.text.isNotBlank() &&
+ !isUploadingImage &&
+ !wantsInvoice &&
+ contentToAddUrl == null
+ }
+
+ suspend fun createNIP94Record(
+ uploadingResult: Nip96Uploader.PartialEvent,
+ localContentType: String?,
+ alt: String?,
+ sensitiveContent: Boolean,
+ ) {
+ // Images don't seem to be ready immediately after upload
+ val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
+ val remoteMimeType =
+ uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
+ val originalHash =
+ uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
+ val dim =
+ uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
+ val magnet =
+ uploadingResult.tags
+ ?.firstOrNull { it.size > 1 && it[0] == "magnet" }
+ ?.get(1)
+ ?.ifBlank { null }
+
+ if (imageUrl.isNullOrBlank()) {
+ Log.e("ImageDownload", "Couldn't download image from server")
+ cancel()
+ isUploadingImage = false
+ viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
+ return
+ }
+
+ FileHeader.prepare(
+ fileUrl = imageUrl,
+ mimeType = remoteMimeType ?: localContentType,
+ dimPrecomputed = dim,
+ onReady = { header: FileHeader ->
+ account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
+ isUploadingImage = false
+ nip94attachments = nip94attachments + event
+
+ message = message.insertUrlAtCursor(imageUrl)
+ urlPreview = findUrlInMessage()
+ }
+ },
+ onError = {
+ isUploadingImage = false
+ viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
+ },
+ )
+ }
+
+ fun createNIP95Record(
+ bytes: ByteArray,
+ mimeType: String?,
+ alt: String?,
+ sensitiveContent: Boolean,
+ ) {
+ if (bytes.size > 80000) {
+ viewModelScope.launch {
+ imageUploadingError.emit("Media is too big for NIP-95")
+ isUploadingImage = false
+ }
+ return
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ FileHeader.prepare(
+ bytes,
+ mimeType,
+ null,
+ onReady = {
+ account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
+ nip95attachments = nip95attachments + nip95
+ val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
+
+ isUploadingImage = false
+
+ note?.let {
+ message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
+ }
+
+ urlPreview = findUrlInMessage()
+ }
+ },
+ onError = {
+ isUploadingImage = false
+ viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
+ },
+ )
+ }
+ }
+
+ fun selectImage(uri: Uri) {
+ contentToAddUrl = uri
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt
index 8406c09bd..d6bf6f367 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt
@@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -142,7 +142,10 @@ fun JoinUserOrChannelView(
) {
Surface {
Column(
- modifier = Modifier.padding(10.dp).heightIn(min = 500.dp),
+ modifier =
+ Modifier
+ .padding(10.dp)
+ .heightIn(min = 500.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -267,7 +270,10 @@ private fun SearchEditTextForJoin(
}
Row(
- modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
+ modifier =
+ Modifier
+ .padding(horizontal = 10.dp)
+ .fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -280,7 +286,8 @@ private fun SearchEditTextForJoin(
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
modifier =
- Modifier.weight(1f, true)
+ Modifier
+ .weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
@@ -330,7 +337,11 @@ private fun RenderSearchResults(
}
Row(
- modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(vertical = 10.dp),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -411,7 +422,10 @@ fun UserComposeForChat(
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
- modifier = Modifier.padding(start = 10.dp).weight(1f),
+ modifier =
+ Modifier
+ .padding(start = 10.dp)
+ .weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
@@ -419,7 +433,7 @@ fun UserComposeForChat(
}
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
index dcb217e38..f68a9fd3d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
@@ -88,7 +88,7 @@ fun NewMediaView(
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
- val scroolState = rememberScrollState()
+ val scrollState = rememberScrollState()
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
@@ -173,7 +173,7 @@ fun NewMediaView(
modifier = Modifier.fillMaxWidth().weight(1f),
) {
Column(
- modifier = Modifier.fillMaxWidth().verticalScroll(scroolState),
+ modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
) {
ImageVideoPost(postViewModel, accountViewModel)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
index ee8aeb49e..4ebf5c1be 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
@@ -56,7 +56,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowForwardIos
+import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
+import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
@@ -65,13 +66,12 @@ import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
-import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -124,7 +124,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
-import com.fonfon.kgeohash.toGeoHash
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -136,7 +135,6 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
-import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
@@ -146,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
+import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
@@ -157,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@@ -189,6 +189,7 @@ fun NewPostView(
baseReplyTo: Note? = null,
quote: Note? = null,
fork: Note? = null,
+ version: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -207,13 +208,13 @@ fun NewPostView(
launch(Dispatchers.IO) {
val replyDraft = LocalPreferences.loadReplyDraft(accountViewModel.account)
if (replyDraft.isNullOrBlank()) {
- postViewModel.load(accountViewModel, baseReplyTo, quote, fork)
+ postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
} else {
val note = LocalCache.checkGetOrCreateNote(replyDraft)
if (note == null) {
- postViewModel.load(accountViewModel, baseReplyTo, quote, fork)
+ postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
} else {
- postViewModel.load(accountViewModel, note, quote, fork)
+ postViewModel.load(accountViewModel, note, quote, fork, version)
}
}
@@ -488,34 +489,32 @@ fun NewPostView(
}
}
- val user = postViewModel.account?.userProfile()
- val lud16 = user?.info?.lnAddress()
-
- if (lud16 != null && postViewModel.wantsInvoice) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
- ) {
- Column(Modifier.fillMaxWidth()) {
- InvoiceRequest(
- lud16,
- user.pubkeyHex,
- accountViewModel.account,
- stringResource(id = R.string.lightning_invoice),
- stringResource(id = R.string.lightning_create_and_add_invoice),
- onSuccess = {
- postViewModel.message =
- TextFieldValue(postViewModel.message.text + "\n\n" + it)
- postViewModel.wantsInvoice = false
- },
- onClose = { postViewModel.wantsInvoice = false },
- onError = { title, message -> accountViewModel.toast(title, message) },
- )
+ if (postViewModel.wantsInvoice) {
+ postViewModel.lnAddress()?.let { lud16 ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ InvoiceRequest(
+ lud16,
+ accountViewModel.account.userProfile().pubkeyHex,
+ accountViewModel.account,
+ stringResource(id = R.string.lightning_invoice),
+ stringResource(id = R.string.lightning_create_and_add_invoice),
+ onSuccess = {
+ postViewModel.insertAtCursor(it)
+ postViewModel.wantsInvoice = false
+ },
+ onClose = { postViewModel.wantsInvoice = false },
+ onError = { title, message -> accountViewModel.toast(title, message) },
+ )
+ }
}
}
}
- if (lud16 != null && postViewModel.wantsZapraiser) {
+ if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@@ -582,7 +581,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
}
- if (postViewModel.canAddInvoice) {
+ if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
@@ -739,7 +738,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.add_sensitive_content_explainer),
@@ -786,7 +785,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -820,7 +819,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -861,7 +860,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -904,7 +903,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -969,7 +968,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1033,7 +1032,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1067,7 +1066,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -1101,7 +1100,7 @@ fun FowardZapTo(
tint = BitcoinOrange,
)
Icon(
- imageVector = Icons.Outlined.ArrowForwardIos,
+ imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier =
Modifier
@@ -1119,7 +1118,7 @@ fun FowardZapTo(
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zap_split_explainer),
@@ -1184,23 +1183,12 @@ fun FowardZapTo(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationAsHash(postViewModel: NewPostViewModel) {
- val context = LocalContext.current
-
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (locationPermissionState.status.isGranted) {
- var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) }
-
- DisposableEffect(key1 = Unit) {
- postViewModel.startLocation(context = context)
- locationDescriptionFlow = postViewModel.location
-
- onDispose { postViewModel.stopLocation() }
- }
-
Column(
modifier = Modifier.fillMaxWidth(),
) {
@@ -1231,10 +1219,10 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
modifier = Modifier.padding(start = 10.dp),
)
- locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) }
+ DisplayLocationObserver(postViewModel)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.geohash_explainer),
@@ -1248,41 +1236,39 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
}
@Composable
-fun DisplayLocationObserver(geoLocation: Flow) {
- val location by geoLocation.collectAsStateWithLifecycle(null)
+fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
+ val context = LocalContext.current
+ var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) }
- location?.let { DisplayLocationInTitle(geohash = it) }
+ DisposableEffect(key1 = context) {
+ postViewModel.startLocation(context = context)
+ locationDescriptionFlow = postViewModel.location
+
+ onDispose { postViewModel.stopLocation() }
+ }
+
+ locationDescriptionFlow?.let {
+ val location by it.collectAsStateWithLifecycle(null)
+
+ location?.let { DisplayLocationInTitle(geohash = it) }
+ }
}
@Composable
fun DisplayLocationInTitle(geohash: String) {
- val context = LocalContext.current
-
- var cityName by remember(geohash) { mutableStateOf(geohash) }
-
- LaunchedEffect(key1 = geohash) {
- launch(Dispatchers.IO) {
- val newCityName =
- ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank {
- null
- }
-
- if (newCityName != null && newCityName != cityName) {
- cityName = newCityName
- }
- }
- }
-
- if (geohash != "s0000") {
+ LoadCityName(
+ geohashStr = geohash,
+ onLoading = {
+ Spacer(modifier = StdHorzSpacer)
+ LoadingAnimation()
+ },
+ ) { cityName ->
Text(
text = cityName,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = Size5dp),
)
- } else {
- Spacer(modifier = StdHorzSpacer)
- LoadingAnimation()
}
}
@@ -1478,7 +1464,7 @@ private fun ForwardZapTo(
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
- imageVector = Icons.Default.ArrowForwardIos,
+ imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
@@ -1497,7 +1483,7 @@ private fun ForwardZapTo(
tint = BitcoinOrange,
)
Icon(
- imageVector = Icons.Outlined.ArrowForwardIos,
+ imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
@@ -1561,7 +1547,7 @@ private fun MarkAsSensitive(
)
Icon(
imageVector = Icons.Rounded.Warning,
- contentDescription = null,
+ contentDescription = stringResource(R.string.add_content_warning),
modifier =
Modifier
.size(10.dp)
@@ -1580,7 +1566,7 @@ private fun MarkAsSensitive(
)
Icon(
imageVector = Icons.Rounded.Warning,
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.remove_content_warning),
modifier =
Modifier
.size(10.dp)
@@ -1749,7 +1735,7 @@ fun ImageVideoDescription(
}
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
index 19920809a..285a4139c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
@@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
+import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -164,11 +165,24 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
+ fun lnAddress(): String? {
+ return account?.userProfile()?.info?.lnAddress()
+ }
+
+ fun hasLnAddress(): Boolean {
+ return account?.userProfile()?.info?.lnAddress() != null
+ }
+
+ fun user(): User? {
+ return account?.userProfile()
+ }
+
open fun load(
accountViewModel: AccountViewModel,
replyingTo: Note?,
quote: Note?,
fork: Note?,
+ version: Note?,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
@@ -240,7 +254,7 @@ open class NewPostViewModel() : ViewModel() {
}
fork?.let {
- message = TextFieldValue(it.event?.content() ?: "")
+ message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
@@ -275,12 +289,16 @@ open class NewPostViewModel() : ViewModel() {
}
it.author?.let {
- if (this.pTags?.contains(it) != true) {
+ if (this.pTags == null) {
+ this.pTags = listOf(it)
+ } else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
+ } ?: run {
+ forkedFromNote = null
}
if (!forwardZapTo.items.isEmpty()) {
@@ -427,6 +445,45 @@ open class NewPostViewModel() : ViewModel() {
nip94attachments = usedAttachments,
)
}
+ } else if (originalNote?.event is GitIssueEvent) {
+ val originalNoteEvent = originalNote?.event as GitIssueEvent
+ // adds markers
+ val rootId =
+ originalNoteEvent.rootIssueOrPatch() // if it has a marker as root
+ ?: originalNote
+ ?.replyTo
+ ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }
+ ?.idHex // if it has loaded events with zero replies in the reply list
+ ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
+ ?: originalNote?.idHex
+
+ val replyId = originalNote?.idHex
+
+ val replyToSet =
+ if (forkedFromNote != null) {
+ (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
+ } else {
+ tagger.eTags
+ }
+
+ val repositoryAddress = originalNoteEvent.repository()
+
+ account?.sendGitReply(
+ message = tagger.message,
+ replyTo = replyToSet,
+ mentions = tagger.pTags,
+ repository = repositoryAddress,
+ zapReceiver = zapReceiver,
+ wantsToMarkAsSensitive = wantsToMarkAsSensitive,
+ zapRaiserAmount = localZapRaiserAmount,
+ replyingTo = replyId,
+ root = rootId,
+ directMentions = tagger.directMentions,
+ forkedFrom = forkedFromNote?.event as? Event,
+ relayList = relayList,
+ geohash = geoHash,
+ nip94attachments = usedAttachments,
+ )
} else {
if (wantsPoll) {
account?.sendPoll(
@@ -580,6 +637,8 @@ open class NewPostViewModel() : ViewModel() {
toUsers = TextFieldValue("")
subject = TextFieldValue("")
+ forkedFromNote = null
+
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
@@ -854,6 +913,10 @@ open class NewPostViewModel() : ViewModel() {
)
}
+ fun insertAtCursor(newElement: String) {
+ message = message.insertUrlAtCursor(newElement)
+ }
+
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt
index 2e809ae02..f1ae54813 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt
@@ -45,8 +45,8 @@ import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -272,9 +272,7 @@ fun ServerConfigHeader() {
}
}
- Divider(
- thickness = DividerThickness,
- )
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -459,9 +457,7 @@ fun ServerConfigClickableLine(
}
}
- Divider(
- thickness = DividerThickness,
- )
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -839,7 +835,7 @@ private fun FirstLine(
) {
Icon(
imageVector = Icons.Default.Cancel,
- null,
+ contentDescription = stringResource(id = R.string.remove),
modifier = Modifier.padding(start = 10.dp).size(15.dp),
tint = WarningColor,
)
@@ -875,7 +871,7 @@ fun EditableServerConfig(
IconButton(onClick = { read = !read }) {
Icon(
imageVector = Icons.Default.Download,
- null,
+ contentDescription = stringResource(id = R.string.read_from_relay),
modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp),
tint =
if (read) {
@@ -889,7 +885,7 @@ fun EditableServerConfig(
IconButton(onClick = { write = !write }) {
Icon(
imageVector = Icons.Default.Upload,
- null,
+ contentDescription = stringResource(id = R.string.write_to_relay),
modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp),
tint =
if (write) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt
index c19d7531c..672e7acb8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt
@@ -68,6 +68,7 @@ fun NotifyRequestDialog(
Modifier.fillMaxWidth(),
EmptyTagList,
background,
+ textContent,
accountViewModel,
nav,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt
index c77dd6782..739a470fd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt
@@ -35,16 +35,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
-import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -66,7 +66,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ThemeType
-import com.vitorpamplona.amethyst.service.CashuProcessor
+import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CashuIcon
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
@@ -85,25 +86,26 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
@Composable
fun CashuPreview(
cashutoken: String,
accountViewModel: AccountViewModel,
) {
- var cachuData by remember {
- mutableStateOf>(GenericLoadable.Loading())
- }
-
- LaunchedEffect(key1 = cashutoken) {
- launch(Dispatchers.IO) {
- val newCachuData = CashuProcessor().parse(cashutoken)
- launch(Dispatchers.Main) { cachuData = newCachuData }
+ val cashuData by produceState(
+ initialValue = CachedCashuProcessor.cached(cashutoken),
+ key1 = cashutoken,
+ ) {
+ withContext(Dispatchers.IO) {
+ val newToken = CachedCashuProcessor.parse(cashutoken)
+ if (value != newToken) {
+ value = newToken
+ }
}
}
- Crossfade(targetState = cachuData, label = "CashuPreview(") {
+ Crossfade(targetState = cashuData, label = "CashuPreview") {
when (it) {
is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error ->
@@ -160,17 +162,24 @@ fun CashuPreview(
Column(
modifier =
- Modifier.fillMaxWidth()
+ Modifier
+ .fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
- modifier = Modifier.fillMaxWidth().padding(20.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.cashu),
@@ -187,17 +196,23 @@ fun CashuPreview(
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
- modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
)
Row(
- modifier = Modifier.padding(top = 5.dp).fillMaxWidth(),
+ modifier =
+ Modifier
+ .padding(top = 5.dp)
+ .fillMaxWidth(),
) {
var isRedeeming by remember { mutableStateOf(false) }
@@ -289,13 +304,17 @@ fun CashuPreviewNew(
Card(
modifier =
- Modifier.fillMaxWidth()
+ Modifier
+ .fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.fillMaxWidth().padding(10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -317,6 +336,7 @@ fun CashuPreviewNew(
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 20.sp,
+ modifier = Modifier.padding(top = 5.dp),
)
Row(modifier = Modifier.padding(top = 5.dp)) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt
index 477e7ac2b..cc784e243 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
+import android.util.LruCache
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -51,6 +52,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.ImmutableListOfLists
+object ShowFullTextCache {
+ val cache = LruCache(20)
+}
+
@Composable
fun ExpandableRichTextViewer(
content: String,
@@ -58,10 +63,19 @@ fun ExpandableRichTextViewer(
modifier: Modifier,
tags: ImmutableListOfLists,
backgroundColor: MutableState,
+ id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- var showFullText by remember { mutableStateOf(false) }
+ var showFullText by remember {
+ val cached = ShowFullTextCache.cache[id]
+ if (cached == null) {
+ ShowFullTextCache.cache.put(id, false)
+ mutableStateOf(false)
+ } else {
+ mutableStateOf(cached)
+ }
+ }
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
@@ -96,7 +110,10 @@ fun ExpandableRichTextViewer(
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
- ShowMoreButton { showFullText = !showFullText }
+ ShowMoreButton {
+ showFullText = !showFullText
+ ShowFullTextCache.cache.put(id, showFullText)
+ }
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
index 497f70f51..1b2a8efd1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
@@ -28,16 +28,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -52,43 +51,31 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
+import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
-import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import java.text.NumberFormat
-
-@Stable data class InvoiceAmount(val invoice: String, val amount: String?)
+import kotlinx.coroutines.withContext
@Composable
fun LoadValueFromInvoice(
lnbcWord: String,
inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit,
) {
- var lnInvoice by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = lnbcWord) {
- launch(Dispatchers.IO) {
- val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
- if (myInvoice != null) {
- val myInvoiceAmount =
- try {
- NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- e.printStackTrace()
- null
- }
-
- lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
+ val lnInvoice by
+ produceState(initialValue = CachedLnInvoiceParser.cached(lnbcWord), key1 = lnbcWord) {
+ withContext(Dispatchers.IO) {
+ val newLnInvoice = CachedLnInvoiceParser.parse(lnbcWord)
+ if (value != newLnInvoice) {
+ value = newLnInvoice
+ }
}
}
- }
inner(lnInvoice)
}
@@ -128,17 +115,24 @@ fun InvoicePreview(
Column(
modifier =
- Modifier.fillMaxWidth()
+ Modifier
+ .fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
- modifier = Modifier.fillMaxWidth().padding(20.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.lightning),
@@ -155,19 +149,25 @@ fun InvoicePreview(
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
amount?.let {
Text(
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
- modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
)
}
Button(
- modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } },
shape = QuoteBorder,
colors =
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
index 12a91502a..22b913d43 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -131,7 +132,7 @@ fun InvoiceRequest(
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt
index 2d71b7079..ca0b2cb5e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt
@@ -24,9 +24,8 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.MediaUrlImage
@@ -46,19 +45,16 @@ fun LoadUrlPreview(
if (!automaticallyShowUrlPreview) {
ClickableUrl(urlText, url)
} else {
- var urlPreviewState by
- remember(url) {
- mutableStateOf(
- UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
- )
+ val urlPreviewState by
+ produceState(
+ initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
+ key1 = url,
+ ) {
+ if (value == UrlPreviewState.Loading) {
+ accountViewModel.urlPreview(url) { value = it }
+ }
}
- // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are
- // created).
- if (urlPreviewState == UrlPreviewState.Loading) {
- LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } }
- }
-
Crossfade(
targetState = urlPreviewState,
animationSpec = tween(durationMillis = 100),
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
index e6d8a1fef..97f90b808 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
@@ -344,6 +344,17 @@ private fun RenderRegular(
}
}
}
+
+ /*
+ // UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text.
+ val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull()
+ if (lastElement !is ImageSegment &&
+ lastElement !is LinkSegment &&
+ lastElement !is InvoiceSegment &&
+ lastElement !is CashuSegment
+ ) {
+ Spacer(modifier = StdVertSpacer)
+ }*/
}
}
@@ -486,7 +497,11 @@ private fun RenderContentAsMarkdown(
ZoomableContentView(
content =
remember(destination, tags) {
- RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: MediaUrlImage(url = destination)
+ RichTextParser().parseMediaUrl(
+ destination,
+ tags ?: EmptyTagList,
+ title.ifEmpty { null } ?: content,
+ ) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content)
},
roundedCorner = true,
accountViewModel = accountViewModel,
@@ -647,7 +662,7 @@ fun BechLink(
accountViewModel,
backgroundColor,
nav,
- loadedLink!!,
+ loadedLink?.nip19?.additionalChars?.ifBlank { null },
)
}
} else if (loadedLink?.nip19 != null) {
@@ -672,7 +687,7 @@ private fun DisplayFullNote(
accountViewModel: AccountViewModel,
backgroundColor: MutableState,
nav: (String) -> Unit,
- loadedLink: LoadedBechLink,
+ extraChars: String?,
) {
NoteCompose(
baseNote = it,
@@ -683,8 +698,6 @@ private fun DisplayFullNote(
nav = nav,
)
- val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } }
-
extraChars?.let {
Text(
it,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt
index afda98789..52b3844c7 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt
@@ -30,7 +30,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24dp
@Composable
@@ -75,7 +76,7 @@ fun SelectTextDialog(
}
Text(text = stringResource(R.string.select_text_dialog_top))
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
index 8c2141b05..aceca0ecf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
@@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -182,7 +182,7 @@ fun SpinnerSelectionDialog(
fontWeight = FontWeight.Bold,
)
}
- Divider(color = Color.LightGray, thickness = DividerThickness)
+ HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
itemsIndexed(options) { index, item ->
@@ -192,7 +192,7 @@ fun SpinnerSelectionDialog(
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
- Divider(color = Color.LightGray, thickness = DividerThickness)
+ HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
index 40001604b..3d7e00abc 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
@@ -282,7 +282,7 @@ fun VideoView(
}
@Composable
-@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+@OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoViewInner(
videoUri: String,
defaultToStart: Boolean = false,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt
index 428e1086f..8d59c824c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt
@@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -72,7 +73,7 @@ fun ZapRaiserRequest(
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zapraiser_explainer),
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
index a92a0d4a5..5e49b616e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
@@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
+import com.vitorpamplona.quartz.events.GitIssueEvent
+import com.vitorpamplona.quartz.events.GitPatchEvent
+import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.MuteListEvent
@@ -94,13 +97,23 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter()
): Boolean {
val event = note.event
+ if (event is GitIssueEvent || event is GitPatchEvent) {
+ return true
+ }
+
+ if (event is HighlightEvent) {
+ return true
+ }
+
if (event is BaseTextNoteEvent) {
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true
val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
val isAuthorDirectlyCited = event.citedUsers().contains(authorHex)
+ val isAuthorOfAFork =
+ event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true)
- return isAuthoredPostCited || isAuthorDirectlyCited
+ return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork
}
if (event is ReactionEvent) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt
index d40a92afe..ec810f28f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,7 +41,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
-import com.vitorpamplona.amethyst.ui.note.TimeAgo
+import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -73,7 +73,7 @@ fun ChannelNamePreview() {
onClick = {},
)
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
ListItem(
headlineContent = {
@@ -135,7 +135,7 @@ fun ChatHeaderLayout(
}
}
- Divider(
+ HorizontalDivider(
modifier = StdTopPadding,
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
index 102a143eb..008800784 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
@@ -132,7 +132,7 @@ private fun RenderBottomMenu(
nav: (Route, Boolean) -> Unit,
) {
Column(modifier = BottomTopHeight) {
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
NavigationBar(tonalElevation = Size0dp) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
index c5dfcf639..e661a20e9 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
@@ -39,9 +39,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
-import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -74,7 +74,6 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import coil.Coil
-import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -108,12 +107,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.LoadUser
-import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.SearchIcon
-import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
+import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader
@@ -395,8 +394,6 @@ private fun ChannelTopBar(
}
}
-@Composable fun NoTopBar() {}
-
@Composable
fun StoriesTopBar(
followLists: FollowListViewModel,
@@ -404,7 +401,7 @@ fun StoriesTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
+ GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle()
FollowListWithRoutes(
@@ -423,7 +420,7 @@ fun HomeTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
+ GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle()
FollowListWithRoutes(
@@ -446,7 +443,7 @@ fun NotificationTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
+ GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle()
FollowListWithoutRoutes(
@@ -465,7 +462,7 @@ fun DiscoveryTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
+ GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle()
FollowListWithoutRoutes(
@@ -492,7 +489,7 @@ fun GenericMainTopBar(
drawerState: DrawerState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
- content: @Composable (AccountViewModel) -> Unit,
+ content: @Composable () -> Unit,
) {
Column(modifier = BottomTopHeight) {
TopAppBar(
@@ -511,7 +508,7 @@ fun GenericMainTopBar(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
- content(accountViewModel)
+ content()
}
}
}
@@ -522,7 +519,7 @@ fun GenericMainTopBar(
},
actions = { SearchButton { nav(Route.Search.route) } },
)
- Divider(thickness = DividerThickness)
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -817,15 +814,12 @@ fun SimpleTextSpinner(
fun RenderOption(option: Name) {
when (option) {
is GeoHashName -> {
- val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull()
- if (geohash != null) {
- LoadCityName(geohash) {
- Row(
- horizontalArrangement = Arrangement.Center,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
- }
+ LoadCityName(option.geoHashTag) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
}
}
@@ -893,7 +887,7 @@ fun TopBarWithBackButton(
},
actions = {},
)
- Divider(thickness = DividerThickness)
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -911,7 +905,7 @@ fun FlexibleTopBarWithBackButton(
actions = {},
)
Spacer(modifier = HalfVertSpacer)
- Divider(thickness = DividerThickness)
+ HorizontalDivider(thickness = DividerThickness)
}
}
@@ -1068,8 +1062,6 @@ fun MyExtensibleTopAppBar(
}
}
-private val AppBarHeight = 50.dp
-
// TODO: this should probably be part of the touch target of the start and end icons, clarify this
private val AppBarHorizontalPadding = 4.dp
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
index d61df96f6..3a352a372 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
@@ -48,8 +48,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerState
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -78,12 +78,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.PostButton
+import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
@@ -163,7 +167,7 @@ fun DrawerContent(
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
modifier = Modifier.padding(top = 20.dp),
)
@@ -872,7 +876,7 @@ fun BottomContent(
var dialogOpen by remember { mutableStateOf(false) }
Column(modifier = Modifier) {
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 15.dp),
thickness = DividerThickness,
)
@@ -883,29 +887,24 @@ fun BottomContent(
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Text(
+ ClickableText(
+ text =
+ buildAnnotatedString {
+ withStyle(
+ SpanStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ ) {
+ append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase())
+ }
+ },
+ onClick = {
+ nav("Note/${BuildConfig.RELEASE_NOTES_ID}")
+ coroutineScope.launch { drawerState.close() }
+ },
modifier = Modifier.padding(start = 16.dp),
- text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(),
- fontSize = 12.sp,
- fontWeight = FontWeight.Bold,
)
- /*
- IconButton(
- onClick = {
- when (AppCompatDelegate.getDefaultNightMode()) {
- AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
- AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
- else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
- }
- }
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_theme),
- null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- }*/
Box(modifier = Modifier.weight(1F))
IconButton(
onClick = {
@@ -915,7 +914,7 @@ fun BottomContent(
) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
- null,
+ contentDescription = stringResource(id = R.string.show_npub_as_a_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
index ecdc18bf2..8ad6a0d5b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -32,7 +31,6 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
-import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -240,16 +238,6 @@ sealed class Route(
}
}
-// **
-// * Functions below only exist because we have not broken the datasource classes into backend and
-// frontend.
-// **
-@Composable
-fun currentRoute(navController: NavHostController): String? {
- val navBackStackEntry by navController.currentBackStackEntryAsState()
- return navBackStackEntry?.destination?.route
-}
-
open class LatestItem {
var newestItemPerAccount: Map = mapOf()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt
index 007f5ea66..f85d8fbaf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt
@@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MilitaryTech
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -83,7 +84,7 @@ fun BadgeCompose(
val scope = rememberCoroutineScope()
if (note == null) {
- BlankNote(Modifier, isInnerNote)
+ BlankNote(Modifier, !isInnerNote)
} else {
val defaultBackgroundColor = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) }
@@ -162,12 +163,12 @@ fun BadgeCompose(
) {
Icon(
imageVector = Icons.Default.MoreVert,
- null,
+ contentDescription = stringResource(id = R.string.more_options),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
- NoteDropDownMenu(note, popupExpanded, accountViewModel)
+ NoteDropDownMenu(note, popupExpanded, null, accountViewModel, nav)
}
}
@@ -183,7 +184,7 @@ fun BadgeCompose(
)
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt
index 81823b0c9..878dec216 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -37,20 +36,34 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size35dp
+import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentSetOf
+
+@Composable
+@Preview
+fun BlankNotePreview() {
+ ThemeComparisonColumn(
+ onDark = { BlankNote() },
+ onLight = { BlankNote() },
+ )
+}
@Composable
fun BlankNote(
modifier: Modifier = Modifier,
- showDivider: Boolean = false,
+ showDivider: Boolean = true,
idHex: String? = null,
) {
Column(modifier = modifier) {
@@ -62,7 +75,7 @@ fun BlankNote(
start = 20.dp,
end = 20.dp,
bottom = 8.dp,
- top = 15.dp,
+ top = 8.dp,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
@@ -71,10 +84,11 @@ fun BlankNote(
text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
+ textAlign = TextAlign.Center,
)
}
- if (!showDivider) {
+ if (showDivider) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
@@ -85,6 +99,32 @@ fun BlankNote(
}
}
+@Composable
+@Preview
+fun HiddenNotePreview() {
+ val accountViewModel = mockAccountViewModel()
+ val nav: (String) -> Unit = {}
+
+ ThemeComparisonColumn(
+ onDark = {
+ HiddenNote(
+ reports = persistentSetOf(),
+ isHiddenAuthor = true,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ ) {}
+ },
+ onLight = {
+ HiddenNote(
+ reports = persistentSetOf(),
+ isHiddenAuthor = true,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ ) {}
+ },
+ )
+}
+
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
@@ -144,8 +184,61 @@ fun HiddenNote(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
}
+
+@Preview
+@Composable
+fun HiddenNoteByMePreview() {
+ ThemeComparisonColumn(
+ onDark = { HiddenNoteByMe {} },
+ onLight = { HiddenNoteByMe {} },
+ )
+}
+
+@Composable
+fun HiddenNoteByMe(
+ modifier: Modifier = Modifier,
+ isQuote: Boolean = false,
+ onClick: () -> Unit,
+) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(30.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.post_was_hidden),
+ color = Color.Gray,
+ )
+
+ Button(
+ modifier = Modifier.padding(top = 10.dp),
+ onClick = onClick,
+ shape = ButtonBorder,
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.primary,
+ ),
+ contentPadding = ButtonPadding,
+ ) {
+ Text(text = stringResource(R.string.show_anyway), color = Color.White)
+ }
+ }
+ }
+
+ if (!isQuote) {
+ HorizontalDivider(
+ thickness = DividerThickness,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt
index e1c59eede..7443a653f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt
@@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -400,7 +400,7 @@ fun InnerCardRow(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
index 4d647c181..38ce86483 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
@@ -641,6 +641,7 @@ private fun RenderRegularTextNote(
modifier = HalfTopPadding,
tags = tags,
backgroundColor = backgroundBubbleColor,
+ id = note.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -652,6 +653,7 @@ private fun RenderRegularTextNote(
modifier = HalfTopPadding,
tags = EmptyTagList,
backgroundColor = backgroundBubbleColor,
+ id = note.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
index 1e0616bd3..4f714d59d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
@@ -22,7 +22,10 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear
@@ -33,11 +36,8 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Report
-import androidx.compose.material.icons.filled.VolumeOff
-import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleButton
fun AmethystIcon(iconSize: Dp) {
Icon(
painter = painterResource(R.drawable.amethyst),
- null,
+ contentDescription = stringResource(id = R.string.app_logo),
modifier = Modifier.size(iconSize),
tint = Color.Unspecified,
)
@@ -81,7 +81,7 @@ fun FollowingIcon(iconSize: Dp) {
@Composable
fun ArrowBackIcon() {
Icon(
- imageVector = Icons.Default.ArrowBack,
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.grayText,
)
@@ -104,7 +104,7 @@ fun DownloadForOfflineIcon(
) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
- null,
+ contentDescription = stringResource(id = R.string.accessibility_download_for_offline),
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = tint,
)
@@ -237,7 +237,7 @@ fun OpenInNewIcon(
tint: Color = Color.Unspecified,
) {
Icon(
- imageVector = Icons.Default.OpenInNew,
+ imageVector = Icons.AutoMirrored.Filled.OpenInNew,
stringResource(id = R.string.copy_to_clipboard),
tint = tint,
modifier = modifier,
@@ -320,7 +320,7 @@ fun RegularPostIcon() {
fun CancelIcon() {
Icon(
imageVector = Icons.Default.Cancel,
- null,
+ contentDescription = stringResource(id = R.string.cancel),
modifier = Size30Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -338,7 +338,7 @@ fun CloseIcon() {
@Composable
fun MutedIcon() {
Icon(
- imageVector = Icons.Default.VolumeOff,
+ imageVector = Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = stringResource(id = R.string.muted_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
@@ -348,7 +348,7 @@ fun MutedIcon() {
@Composable
fun MuteIcon() {
Icon(
- imageVector = Icons.Default.VolumeUp,
+ imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = stringResource(id = R.string.mute_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
@@ -375,7 +375,7 @@ fun PlayIcon(
) {
Icon(
imageVector = Icons.Outlined.PlayCircle,
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.accessibility_play_username),
modifier = modifier,
tint = tint,
)
@@ -401,7 +401,7 @@ fun LyricsIcon(
) {
Icon(
painter = painterResource(id = R.drawable.lyrics_on),
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.accessibility_lyrics_on),
modifier = modifier,
tint = tint,
)
@@ -414,7 +414,7 @@ fun LyricsOffIcon(
) {
Icon(
painter = painterResource(id = R.drawable.lyrics_off),
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.accessibility_lyrics_off),
modifier = modifier,
tint = tint,
)
@@ -480,3 +480,29 @@ fun NIP05FailedVerification(modifier: Modifier) {
tint = Color.Red,
)
}
+
+@Composable
+fun IncognitoIconOn(
+ modifier: Modifier,
+ tint: Color,
+) {
+ Icon(
+ painter = painterResource(id = R.drawable.incognito),
+ contentDescription = stringResource(id = R.string.accessibility_turn_off_sealed_message),
+ modifier = modifier,
+ tint = tint,
+ )
+}
+
+@Composable
+fun IncognitoIconOff(
+ modifier: Modifier,
+ tint: Color,
+) {
+ Icon(
+ painter = painterResource(id = R.drawable.incognito_off),
+ contentDescription = stringResource(id = R.string.accessibility_turn_on_sealed_message),
+ modifier = modifier,
+ tint = tint,
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt
new file mode 100644
index 000000000..4ec5f2eac
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt
@@ -0,0 +1,243 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import com.fonfon.kgeohash.toGeoHash
+import com.vitorpamplona.amethyst.model.AddressableNote
+import com.vitorpamplona.amethyst.model.Channel
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.service.CachedGeoLocations
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
+import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.encoders.ATag
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun LoadDecryptedContent(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ inner: @Composable (String) -> Unit,
+) {
+ var decryptedContent by
+ remember(note.event) {
+ mutableStateOf(
+ accountViewModel.cachedDecrypt(note),
+ )
+ }
+
+ decryptedContent?.let { inner(it) }
+ ?: run {
+ LaunchedEffect(key1 = decryptedContent) {
+ accountViewModel.decrypt(note) { decryptedContent = it }
+ }
+ }
+}
+
+@Composable
+fun LoadDecryptedContentOrNull(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ inner: @Composable (String?) -> Unit,
+) {
+ var decryptedContent by
+ remember(note.event) {
+ mutableStateOf(
+ accountViewModel.cachedDecrypt(note),
+ )
+ }
+
+ if (decryptedContent == null) {
+ LaunchedEffect(key1 = decryptedContent) {
+ accountViewModel.decrypt(note) { decryptedContent = it }
+ }
+ }
+
+ inner(decryptedContent)
+}
+
+@Composable
+fun LoadAddressableNote(
+ aTagHex: String,
+ accountViewModel: AccountViewModel,
+ content: @Composable (AddressableNote?) -> Unit,
+) {
+ var note by
+ remember(aTagHex) {
+ mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex))
+ }
+
+ if (note == null) {
+ LaunchedEffect(key1 = aTagHex) {
+ accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote ->
+ if (newNote != note) {
+ note = newNote
+ }
+ }
+ }
+ }
+
+ content(note)
+}
+
+@Composable
+fun LoadAddressableNote(
+ aTag: ATag,
+ accountViewModel: AccountViewModel,
+ content: @Composable (AddressableNote?) -> Unit,
+) {
+ var note by
+ remember(aTag) {
+ mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag()))
+ }
+
+ if (note == null) {
+ LaunchedEffect(key1 = aTag) {
+ accountViewModel.getOrCreateAddressableNote(aTag) { newNote ->
+ if (newNote != note) {
+ note = newNote
+ }
+ }
+ }
+ }
+
+ content(note)
+}
+
+@Composable
+fun LoadStatuses(
+ user: User,
+ accountViewModel: AccountViewModel,
+ content: @Composable (ImmutableList) -> Unit,
+) {
+ var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) }
+
+ val userStatus by user.live().statuses.observeAsState()
+
+ LaunchedEffect(key1 = userStatus) {
+ accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
+ if (!equalImmutableLists(statuses, newStatuses)) {
+ statuses = newStatuses
+ }
+ }
+ }
+
+ content(statuses)
+}
+
+@Composable
+fun LoadOts(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ whenConfirmed: @Composable (Long) -> Unit,
+ whenPending: @Composable () -> Unit,
+) {
+ var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) }
+
+ val noteStatus by note.live().innerOts.observeAsState()
+
+ LaunchedEffect(key1 = noteStatus) {
+ accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts ->
+ earliestDate =
+ if (newOts == null) {
+ GenericLoadable.Empty()
+ } else {
+ GenericLoadable.Loaded(newOts)
+ }
+ }
+ }
+
+ (earliestDate as? GenericLoadable.Loaded)?.let {
+ whenConfirmed(it.loaded)
+ } ?: run {
+ val account = accountViewModel.account.saveable.observeAsState()
+ if (account.value?.account?.hasPendingAttestations(note) == true) {
+ whenPending()
+ }
+ }
+}
+
+@Composable
+fun LoadCityName(
+ geohashStr: String,
+ onLoading: (@Composable () -> Unit)? = null,
+ content: @Composable (String) -> Unit,
+) {
+ var cityName by remember(geohashStr) { mutableStateOf(CachedGeoLocations.cached(geohashStr)) }
+
+ if (cityName == null) {
+ if (onLoading != null) {
+ onLoading()
+ }
+
+ val context = LocalContext.current
+
+ LaunchedEffect(key1 = geohashStr, context) {
+ launch(Dispatchers.IO) {
+ val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull()
+ if (geoHash != null) {
+ val newCityName =
+ CachedGeoLocations.geoLocate(geohashStr, geoHash.toLocation(), context)
+ ?.ifBlank { null }
+ if (newCityName != null && newCityName != cityName) {
+ cityName = newCityName
+ }
+ }
+ }
+ }
+ } else {
+ cityName?.let { content(it) }
+ }
+}
+
+@Composable
+fun LoadChannel(
+ baseChannelHex: String,
+ accountViewModel: AccountViewModel,
+ content: @Composable (Channel) -> Unit,
+) {
+ var channel by
+ remember(baseChannelHex) {
+ mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex))
+ }
+
+ if (channel == null) {
+ LaunchedEffect(key1 = baseChannelHex) {
+ accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
+ launch(Dispatchers.Main) { channel = newChannel }
+ }
+ }
+ }
+
+ channel?.let { content(it) }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
index 3e75719d9..e5beeccf9 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
@@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -129,11 +130,11 @@ fun MessageSetCompose(
nav = nav,
)
- NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
+ NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
index 3226d05cc..b36e64c93 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
@@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -70,10 +70,12 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor
import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -158,7 +160,7 @@ fun MultiSetCompose(
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
- modifier = remember { Modifier.padding(top = 5.dp) },
+ modifier = HalfTopPadding,
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
@@ -166,10 +168,10 @@ fun MultiSetCompose(
nav = nav,
)
- NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
+ NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -472,6 +474,7 @@ fun CrossfadeToDisplayComment(
tags = EmptyTagList,
modifier = textBoxModifier,
backgroundColor = backgroundColor,
+ id = comment,
accountViewModel = accountViewModel,
nav = nav,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index fbab305e1..679cd84b1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -20,43 +20,19 @@
*/
package com.vitorpamplona.amethyst.ui.note
-import android.graphics.Bitmap
-import android.util.Log
import androidx.compose.animation.Crossfade
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.CutCornerShape
-import androidx.compose.foundation.text.ClickableText
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.VerticalDivider
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -69,116 +45,81 @@ 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.Center
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
-import androidx.compose.ui.graphics.luminance
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.capitalize
-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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.core.graphics.drawable.toBitmap
-import androidx.core.graphics.get
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
-import coil.compose.AsyncImage
-import coil.compose.AsyncImagePainter
-import coil.request.SuccessResult
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.fonfon.kgeohash.GeoHash
-import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.commons.BaseMediaContent
-import com.vitorpamplona.amethyst.commons.MediaLocalImage
-import com.vitorpamplona.amethyst.commons.MediaLocalVideo
-import com.vitorpamplona.amethyst.commons.MediaUrlImage
-import com.vitorpamplona.amethyst.commons.MediaUrlVideo
-import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
-import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
-import com.vitorpamplona.amethyst.model.User
-import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
-import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
-import com.vitorpamplona.amethyst.ui.components.ClickableUrl
-import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
-import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
-import com.vitorpamplona.amethyst.ui.components.LoadNote
-import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
-import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
-import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
-import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
-import com.vitorpamplona.amethyst.ui.components.VideoView
-import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
-import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
-import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
-import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
-import com.vitorpamplona.amethyst.ui.elements.AddButton
-import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost
-import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost
-import com.vitorpamplona.amethyst.ui.elements.DisplayPoW
-import com.vitorpamplona.amethyst.ui.elements.DisplayReward
-import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags
-import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
-import com.vitorpamplona.amethyst.ui.elements.RemoveButton
-import com.vitorpamplona.amethyst.ui.elements.Reward
import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.ui.navigation.routeFor
-import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
+import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits
+import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
+import com.vitorpamplona.amethyst.ui.note.elements.Reward
+import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
+import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
+import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
+import com.vitorpamplona.amethyst.ui.note.types.CommunityHeader
+import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList
+import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet
+import com.vitorpamplona.amethyst.ui.note.types.EditState
+import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
+import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
+import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
+import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
+import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
+import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
+import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight
+import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
+import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
+import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
+import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
+import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
+import com.vitorpamplona.amethyst.ui.note.types.RenderReport
+import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
+import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
-import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
-import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
-import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
-import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
-import com.vitorpamplona.amethyst.ui.theme.Size10dp
-import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
-import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
-import com.vitorpamplona.amethyst.ui.theme.Size5dp
-import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
-import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
@@ -186,19 +127,11 @@ import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier
import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
-import com.vitorpamplona.amethyst.ui.theme.imageModifier
-import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
-import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
-import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.normalNoteModifier
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
-import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyBackground
import com.vitorpamplona.amethyst.ui.theme.replyModifier
-import com.vitorpamplona.amethyst.ui.theme.subtleBorder
-import com.vitorpamplona.quartz.encoders.ATag
-import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -212,24 +145,17 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
-import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
-import com.vitorpamplona.quartz.events.EmojiUrl
-import com.vitorpamplona.quartz.events.EmptyTagList
-import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
+import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
-import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
-import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
-import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.quartz.events.LongTextNoteEvent
-import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -239,25 +165,14 @@ import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
-import com.vitorpamplona.quartz.events.UserMetadata
-import com.vitorpamplona.quartz.events.VideoEvent
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
-import com.vitorpamplona.quartz.events.toImmutableListOfLists
-import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.io.File
-import java.net.URL
-import java.text.DecimalFormat
-import java.text.NumberFormat
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -294,8 +209,7 @@ fun NoteCompose(
nav = nav,
)
} else {
- LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup,
- ->
+ LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
@@ -303,7 +217,7 @@ fun NoteCompose(
onLongClick = showPopup,
)
},
- isBoostedNote || isQuotedNote,
+ !isBoostedNote && !isQuotedNote,
)
}
}
@@ -357,8 +271,13 @@ fun CheckHiddenNoteCompose(
}
.observeAsState(accountViewModel.isNoteHidden(note))
+ val showAnyway =
+ remember {
+ mutableStateOf(false)
+ }
+
Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") {
- if (!it) {
+ if (!it || showAnyway.value) {
LoadedNoteCompose(
note = note,
routeForLastRead = routeForLastRead,
@@ -372,6 +291,11 @@ fun CheckHiddenNoteCompose(
accountViewModel = accountViewModel,
nav = nav,
)
+ } else if (isQuotedNote || isBoostedNote) {
+ HiddenNoteByMe(
+ isQuote = true,
+ onClick = { showAnyway.value = true },
+ )
}
}
}
@@ -439,8 +363,7 @@ fun RenderReportState(
) {
var showReportedNote by remember(note) { mutableStateOf(false) }
- Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") {
- showHiddenNote ->
+ Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { showHiddenNote ->
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
@@ -597,313 +520,6 @@ fun NormalNote(
}
}
-@Composable
-fun CommunityHeader(
- baseNote: AddressableNote,
- showBottomDiviser: Boolean,
- sendToCommunity: Boolean,
- modifier: Modifier = StdPadding,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val expanded = remember { mutableStateOf(false) }
-
- Column(Modifier.fillMaxWidth()) {
- Column(
- verticalArrangement = Arrangement.Center,
- modifier =
- Modifier.clickable {
- if (sendToCommunity) {
- routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) }
- } else {
- expanded.value = !expanded.value
- }
- },
- ) {
- ShortCommunityHeader(
- baseNote = baseNote,
- accountViewModel = accountViewModel,
- nav = nav,
- )
-
- if (expanded.value) {
- Column(Modifier.verticalScroll(rememberScrollState())) {
- LongCommunityHeader(
- baseNote = baseNote,
- lineModifier = modifier,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
- }
-
- if (showBottomDiviser) {
- Divider(
- thickness = DividerThickness,
- )
- }
- }
-}
-
-@Composable
-fun LongCommunityHeader(
- baseNote: AddressableNote,
- lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteState by baseNote.live().metadata.observeAsState()
- val noteEvent =
- remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
-
- Row(
- lineModifier,
- ) {
- val rulesLabel = stringResource(id = R.string.rules)
- val summary =
- remember(noteState) {
- val subject = noteEvent.subject()?.ifEmpty { null }
- val body = noteEvent.description()?.ifBlank { null }
- val rules = noteEvent.rules()?.ifBlank { null }
-
- if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
- if (rules == null) {
- "### $subject\n$body"
- } else {
- "### $subject\n$body\n\n### $rulesLabel\n\n$rules"
- }
- } else {
- if (rules == null) {
- body
- } else {
- "$body\n\n$rulesLabel\n$rules"
- }
- }
- }
-
- Column(
- Modifier.weight(1f),
- ) {
- Row(verticalAlignment = CenterVertically) {
- val defaultBackground = MaterialTheme.colorScheme.background
- val background = remember { mutableStateOf(defaultBackground) }
-
- TranslatableRichTextViewer(
- content = summary ?: stringResource(id = R.string.community_no_descriptor),
- canPreview = false,
- tags = EmptyTagList,
- backgroundColor = background,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- if (summary != null && noteEvent.hasHashtags()) {
- DisplayUncitedHashtags(
- remember(noteEvent) { noteEvent.hashtags().toImmutableList() },
- summary ?: "",
- nav,
- )
- }
- }
-
- Column {
- Row {
- Spacer(DoubleHorzSpacer)
- LongCommunityActionOptions(baseNote, accountViewModel, nav)
- }
- }
- }
-
- Row(
- lineModifier,
- verticalAlignment = CenterVertically,
- ) {
- Text(
- text = stringResource(id = R.string.owner),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.width(75.dp),
- )
- Spacer(DoubleHorzSpacer)
- NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
- Spacer(DoubleHorzSpacer)
- NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
- }
-
- var participantUsers by
- remember(baseNote) {
- mutableStateOf>>(
- persistentListOf(),
- )
- }
-
- LaunchedEffect(key1 = noteState) {
- val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
-
- if (participants != null) {
- accountViewModel.loadParticipants(participants) { newParticipantUsers ->
- if (
- newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)
- ) {
- participantUsers = newParticipantUsers
- }
- }
- }
- }
-
- participantUsers.forEach {
- Row(
- lineModifier.clickable { nav("User/${it.second.pubkeyHex}") },
- verticalAlignment = CenterVertically,
- ) {
- it.first.role?.let { it1 ->
- Text(
- text = it1.capitalize(Locale.ROOT),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.width(75.dp),
- )
- }
- Spacer(DoubleHorzSpacer)
- ClickableUserPicture(it.second, Size25dp, accountViewModel)
- Spacer(DoubleHorzSpacer)
- UsernameDisplay(it.second, remember { Modifier.weight(1f) })
- }
- }
-
- Row(
- lineModifier,
- verticalAlignment = CenterVertically,
- ) {
- Text(
- text = stringResource(id = R.string.created_at),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.width(75.dp),
- )
- Spacer(DoubleHorzSpacer)
- NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
- MoreOptionsButton(baseNote, accountViewModel)
- }
-}
-
-@Composable
-fun ShortCommunityHeader(
- baseNote: AddressableNote,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteState by baseNote.live().metadata.observeAsState()
- val noteEvent =
- remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
-
- val automaticallyShowProfilePicture =
- remember {
- accountViewModel.settings.showProfilePictures.value
- }
-
- Row(verticalAlignment = CenterVertically) {
- noteEvent.image()?.let {
- RobohashFallbackAsyncImage(
- robot = baseNote.idHex,
- model = it,
- contentDescription = stringResource(R.string.profile_image),
- contentScale = ContentScale.Crop,
- modifier = HeaderPictureModifier,
- loadProfilePicture = automaticallyShowProfilePicture,
- )
- }
-
- Column(
- modifier =
- Modifier
- .padding(start = 10.dp)
- .height(Size35dp)
- .weight(1f),
- verticalArrangement = Arrangement.Center,
- ) {
- Row(verticalAlignment = CenterVertically) {
- Text(
- text = remember(noteState) { noteEvent.dTag() },
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
-
- Row(
- modifier =
- Modifier
- .height(Size35dp)
- .padding(start = 5.dp),
- verticalAlignment = CenterVertically,
- ) {
- ShortCommunityActionOptions(baseNote, accountViewModel, nav)
- }
- }
-}
-
-@Composable
-private fun ShortCommunityActionOptions(
- note: AddressableNote,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- Spacer(modifier = StdHorzSpacer)
- LikeReaction(
- baseNote = note,
- grayTint = MaterialTheme.colorScheme.onSurface,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- Spacer(modifier = StdHorzSpacer)
- ZapReaction(
- baseNote = note,
- grayTint = MaterialTheme.colorScheme.onSurface,
- accountViewModel = accountViewModel,
- nav = nav,
- )
-
- WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
- if (!isFollowing) {
- Spacer(modifier = StdHorzSpacer)
- JoinCommunityButton(accountViewModel, note, nav)
- }
- }
-}
-
-@Composable
-fun WatchAddressableNoteFollows(
- note: AddressableNote,
- accountViewModel: AccountViewModel,
- onFollowChanges: @Composable (Boolean) -> Unit,
-) {
- val showFollowingMark by
- remember {
- accountViewModel.userFollows
- .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false }
- .distinctUntilChanged()
- }
- .observeAsState(false)
-
- onFollowChanges(showFollowingMark)
-}
-
-@Composable
-private fun LongCommunityActionOptions(
- note: AddressableNote,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
- if (isFollowing) {
- LeaveCommunityButton(accountViewModel, note, nav)
- }
- }
-}
-
@Composable
private fun CheckNewAndRenderNote(
baseNote: Note,
@@ -1028,6 +644,7 @@ fun InnerNoteWithReactions(
nav: (String) -> Unit,
) {
val notBoostedNorQuote = !isBoostedNote && !isQuotedNote
+ val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
Row(
modifier =
@@ -1060,6 +677,7 @@ fun InnerNoteWithReactions(
canPreview = canPreview,
showSecondRow = showSecondRow,
backgroundColor = backgroundColor,
+ editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -1078,6 +696,7 @@ fun InnerNoteWithReactions(
ReactionsRow(
baseNote = baseNote,
showReactionDetail = notBoostedNorQuote,
+ editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -1085,14 +704,14 @@ fun InnerNoteWithReactions(
}
if (notBoostedNorQuote) {
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
}
@Composable
-private fun NoteBody(
+fun NoteBody(
baseNote: Note,
showAuthorPicture: Boolean = false,
unPackReply: Boolean = true,
@@ -1100,12 +719,14 @@ private fun NoteBody(
canPreview: Boolean = true,
showSecondRow: Boolean,
backgroundColor: MutableState,
+ editState: State>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = showAuthorPicture,
+ editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -1133,12 +754,13 @@ private fun NoteBody(
}
RenderNoteRow(
- baseNote,
- backgroundColor,
- makeItShort,
- canPreview,
- accountViewModel,
- nav,
+ baseNote = baseNote,
+ backgroundColor = backgroundColor,
+ makeItShort = makeItShort,
+ canPreview = canPreview,
+ editState = editState,
+ accountViewModel = accountViewModel,
+ nav = nav,
)
val noteEvent = baseNote.event
@@ -1155,6 +777,7 @@ private fun RenderNoteRow(
backgroundColor: MutableState,
makeItShort: Boolean,
canPreview: Boolean,
+ editState: State>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -1221,6 +844,16 @@ private fun RenderNoteRow(
nav,
)
}
+ is GitIssueEvent -> {
+ RenderGitIssueEvent(
+ baseNote,
+ makeItShort,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
+ }
is PrivateDmEvent -> {
RenderPrivateMessage(
baseNote,
@@ -1281,8 +914,8 @@ private fun RenderNoteRow(
nav,
)
}
- else -> {
- RenderTextEvent(
+ is TextNoteModificationEvent -> {
+ RenderTextModificationEvent(
baseNote,
makeItShort,
canPreview,
@@ -1291,690 +924,20 @@ private fun RenderNoteRow(
nav,
)
}
- }
-}
-
-@Composable
-fun LoadDecryptedContent(
- note: Note,
- accountViewModel: AccountViewModel,
- inner: @Composable (String) -> Unit,
-) {
- var decryptedContent by
- remember(note.event) {
- mutableStateOf(
- accountViewModel.cachedDecrypt(note),
- )
- }
-
- decryptedContent?.let { inner(it) }
- ?: run {
- LaunchedEffect(key1 = decryptedContent) {
- accountViewModel.decrypt(note) { decryptedContent = it }
- }
- }
-}
-
-@Composable
-fun LoadDecryptedContentOrNull(
- note: Note,
- accountViewModel: AccountViewModel,
- inner: @Composable (String?) -> Unit,
-) {
- var decryptedContent by
- remember(note.event) {
- mutableStateOf(
- accountViewModel.cachedDecrypt(note),
- )
- }
-
- if (decryptedContent == null) {
- LaunchedEffect(key1 = decryptedContent) {
- accountViewModel.decrypt(note) { decryptedContent = it }
- }
- }
-
- inner(decryptedContent)
-}
-
-@Composable
-fun RenderTextEvent(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- LoadDecryptedContent(note, accountViewModel) { body ->
- val eventContent by
- remember(note.event) {
- derivedStateOf {
- val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
-
- if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
- "### $subject\n$body"
- } else {
- body
- }
- }
- }
-
- val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) }
-
- if (makeItShort && isAuthorTheLoggedUser) {
- Text(
- text = eventContent,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- } else {
- SensitivityWarning(
- note = note,
- accountViewModel = accountViewModel,
- ) {
- val modifier = remember(note) { Modifier.fillMaxWidth() }
- val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- TranslatableRichTextViewer(
- content = eventContent,
- canPreview = canPreview && !makeItShort,
- modifier = modifier,
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- if (note.event?.hasHashtags() == true) {
- val hashtags =
- remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
- DisplayUncitedHashtags(hashtags, eventContent, nav)
- }
- }
- }
-}
-
-@Composable
-fun RenderPoll(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? PollNoteEvent ?: return
- val eventContent = remember(note) { noteEvent.content() }
-
- if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
- Text(
- text = eventContent,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- } else {
- val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- SensitivityWarning(
- note = note,
- accountViewModel = accountViewModel,
- ) {
- TranslatableRichTextViewer(
- content = eventContent,
- canPreview = canPreview && !makeItShort,
- modifier = remember { Modifier.fillMaxWidth() },
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
-
- PollNote(
- note,
- canPreview = canPreview && !makeItShort,
+ else -> {
+ RenderTextEvent(
+ baseNote,
+ makeItShort,
+ canPreview,
backgroundColor,
+ editState,
accountViewModel,
nav,
)
}
-
- if (noteEvent.hasHashtags()) {
- val hashtags = remember { noteEvent.hashtags().toImmutableList() }
- DisplayUncitedHashtags(hashtags, eventContent, nav)
- }
}
}
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-fun RenderAppDefinition(
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? AppDefinitionEvent ?: return
-
- var metadata by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = noteEvent) {
- launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() }
- }
-
- metadata?.let {
- Box {
- val clipboardManager = LocalClipboardManager.current
- val uri = LocalUriHandler.current
-
- if (!it.banner.isNullOrBlank()) {
- var zoomImageDialogOpen by remember { mutableStateOf(false) }
-
- AsyncImage(
- model = it.banner,
- contentDescription = stringResource(id = R.string.profile_image),
- contentScale = ContentScale.FillWidth,
- modifier =
- Modifier
- .fillMaxWidth()
- .height(125.dp)
- .combinedClickable(
- onClick = {},
- onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) },
- ),
- )
-
- if (zoomImageDialogOpen) {
- ZoomableImageDialog(
- imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
- onDismiss = { zoomImageDialogOpen = false },
- accountViewModel = accountViewModel,
- )
- }
- } else {
- Image(
- painter = painterResource(R.drawable.profile_banner),
- contentDescription = stringResource(id = R.string.profile_banner),
- contentScale = ContentScale.FillWidth,
- modifier =
- Modifier
- .fillMaxWidth()
- .height(125.dp),
- )
- }
-
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 10.dp)
- .padding(top = 75.dp),
- ) {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.Bottom,
- ) {
- var zoomImageDialogOpen by remember { mutableStateOf(false) }
-
- Box(Modifier.size(100.dp)) {
- it.picture?.let {
- AsyncImage(
- model = it,
- contentDescription = null,
- contentScale = ContentScale.FillWidth,
- modifier =
- Modifier
- .border(
- 3.dp,
- MaterialTheme.colorScheme.background,
- CircleShape,
- )
- .clip(shape = CircleShape)
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
- .combinedClickable(
- onClick = { zoomImageDialogOpen = true },
- onLongClick = { clipboardManager.setText(AnnotatedString(it)) },
- ),
- )
- }
- }
-
- if (zoomImageDialogOpen) {
- ZoomableImageDialog(
- imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
- onDismiss = { zoomImageDialogOpen = false },
- accountViewModel = accountViewModel,
- )
- }
-
- Spacer(Modifier.weight(1f))
-
- Row(
- modifier =
- Modifier
- .height(Size35dp)
- .padding(bottom = 3.dp),
- ) {}
- }
-
- val name = remember(it) { it.anyName() }
- name?.let {
- Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
- CreateTextWithEmoji(
- text = it,
- tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() },
- fontWeight = FontWeight.Bold,
- fontSize = 25.sp,
- )
- }
- }
-
- val website = remember(it) { it.website }
- if (!website.isNullOrEmpty()) {
- Row(verticalAlignment = CenterVertically) {
- LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
-
- ClickableText(
- text = AnnotatedString(website.removePrefix("https://")),
- onClick = { website.let { runCatching { uri.openUri(it) } } },
- style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
- modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
- )
- }
- }
-
- it.about?.let {
- Row(
- modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
- ) {
- val tags =
- remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
- val bgColor = MaterialTheme.colorScheme.background
- val backgroundColor = remember { mutableStateOf(bgColor) }
- TranslatableRichTextViewer(
- content = it,
- canPreview = false,
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun RenderHighlight(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" }
- val author = remember { (note.event as? HighlightEvent)?.author() }
- val url = remember { (note.event as? HighlightEvent)?.inUrl() }
- val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() }
-
- DisplayHighlight(
- highlight = quote,
- authorHex = author,
- url = url,
- postAddress = postHex,
- makeItShort = makeItShort,
- canPreview = canPreview,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
-}
-
-@Composable
-private fun RenderPrivateMessage(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? PrivateDmEvent ?: return
-
- val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
- if (withMe) {
- LoadDecryptedContent(note, accountViewModel) { eventContent ->
- val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
- val isAuthorTheLoggedUser =
- remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
-
- val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- if (makeItShort && isAuthorTheLoggedUser) {
- Text(
- text = eventContent,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- } else {
- SensitivityWarning(
- note = note,
- accountViewModel = accountViewModel,
- ) {
- TranslatableRichTextViewer(
- content = eventContent,
- canPreview = canPreview && !makeItShort,
- modifier = modifier,
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- if (noteEvent.hasHashtags()) {
- val hashtags =
- remember(note.event?.id()) {
- note.event?.hashtags()?.toImmutableList() ?: persistentListOf()
- }
- DisplayUncitedHashtags(hashtags, eventContent, nav)
- }
- }
- }
- } else {
- val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone"
-
- TranslatableRichTextViewer(
- stringResource(
- id = R.string.private_conversation_notification,
- "@${note.author?.pubkeyNpub()}",
- "@$recipient",
- ),
- canPreview = !makeItShort,
- Modifier.fillMaxWidth(),
- EmptyTagList,
- backgroundColor,
- accountViewModel,
- nav,
- )
- }
-}
-
-@Composable
-fun DisplayRelaySet(
- baseNote: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = baseNote.event as? RelaySetEvent ?: return
-
- val relays by
- remember(baseNote) {
- mutableStateOf(
- noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(),
- )
- }
-
- var expanded by remember { mutableStateOf(false) }
-
- val toMembersShow =
- if (expanded) {
- relays
- } else {
- relays.take(3)
- }
-
- val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } }
-
- val relayDescription by remember { derivedStateOf { noteEvent.description() } }
-
- Text(
- text = relayListName,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(5.dp),
- textAlign = TextAlign.Center,
- )
-
- relayDescription?.let {
- Text(
- text = it,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(5.dp),
- textAlign = TextAlign.Center,
- color = Color.Gray,
- )
- }
-
- Box {
- Column(modifier = Modifier.padding(top = 5.dp)) {
- toMembersShow.forEach { relay ->
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
- Text(
- text = relay.displayUrl,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .padding(start = 10.dp, bottom = 5.dp)
- .weight(1f),
- )
-
- Column(modifier = Modifier.padding(start = 10.dp)) {
- RelayOptionsAction(relay.url, accountViewModel, nav)
- }
- }
- }
- }
-
- if (relays.size > 3 && !expanded) {
- Row(
- verticalAlignment = CenterVertically,
- horizontalArrangement = Arrangement.Center,
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .background(getGradient(backgroundColor)),
- ) {
- ShowMoreButton { expanded = !expanded }
- }
- }
- }
-}
-
-@Composable
-private fun RelayOptionsAction(
- relay: String,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState()
- val isCurrentlyOnTheUsersList by
- remember(userStateRelayInfo) {
- derivedStateOf {
- userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true
- }
- }
-
- var wantsToAddRelay by remember { mutableStateOf("") }
-
- if (wantsToAddRelay.isNotEmpty()) {
- NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav)
- }
-
- if (isCurrentlyOnTheUsersList) {
- AddRelayButton { wantsToAddRelay = relay }
- } else {
- RemoveRelayButton { wantsToAddRelay = relay }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-fun DisplayPeopleList(
- baseNote: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = baseNote.event as? PeopleListEvent ?: return
-
- var members by remember { mutableStateOf>(persistentListOf()) }
-
- var expanded by remember { mutableStateOf(false) }
-
- val toMembersShow =
- if (expanded) {
- members
- } else {
- members.take(3)
- }
-
- val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } }
-
- Text(
- text = name,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(5.dp),
- textAlign = TextAlign.Center,
- )
-
- LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } }
-
- Box {
- FlowRow(modifier = Modifier.padding(top = 5.dp)) {
- toMembersShow.forEach { user ->
- Row(modifier = Modifier.fillMaxWidth()) {
- UserCompose(
- user,
- overallModifier = Modifier,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
- }
-
- if (members.size > 3 && !expanded) {
- Row(
- verticalAlignment = CenterVertically,
- horizontalArrangement = Arrangement.Center,
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .background(getGradient(backgroundColor)),
- ) {
- ShowMoreButton { expanded = !expanded }
- }
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun RenderBadgeAward(
- note: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- if (note.replyTo.isNullOrEmpty()) return
-
- val noteEvent = note.event as? BadgeAwardEvent ?: return
- var awardees by remember { mutableStateOf>(listOf()) }
-
- Text(text = stringResource(R.string.award_granted_to))
-
- LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } }
-
- FlowRow(modifier = Modifier.padding(top = 5.dp)) {
- awardees.take(100).forEach { user ->
- Row(
- modifier =
- Modifier
- .size(size = Size35dp)
- .clickable { nav("User/${user.pubkeyHex}") },
- verticalAlignment = CenterVertically,
- ) {
- ClickableUserPicture(
- baseUser = user,
- accountViewModel = accountViewModel,
- size = Size35dp,
- )
- }
- }
-
- if (awardees.size > 100) {
- Text(" and ${awardees.size - 100} others", maxLines = 1)
- }
- }
-
- note.replyTo?.firstOrNull()?.let {
- NoteCompose(
- it,
- modifier = Modifier,
- isBoostedNote = false,
- isQuotedNote = true,
- unPackReply = false,
- parentBackgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-}
-
-@Composable
-private fun RenderReaction(
- note: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- note.replyTo?.lastOrNull()?.let {
- NoteCompose(
- it,
- modifier = Modifier,
- isBoostedNote = true,
- unPackReply = false,
- parentBackgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- // Reposts have trash in their contents.
- val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: ""
-
- Text(
- text = refactorReactionText,
- maxLines = 1,
- )
-}
-
@Composable
fun RenderRepost(
note: Note,
@@ -1997,328 +960,6 @@ fun RenderRepost(
}
}
-@Composable
-fun RenderPostApproval(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- if (note.replyTo.isNullOrEmpty()) return
-
- val noteEvent = note.event as? CommunityPostApprovalEvent ?: return
-
- Column(Modifier.fillMaxWidth()) {
- noteEvent.communities().forEach {
- LoadAddressableNote(it, accountViewModel) {
- it?.let {
- NoteCompose(
- it,
- parentBackgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
- }
-
- Text(
- text = stringResource(id = R.string.community_approved_posts),
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(5.dp),
- textAlign = TextAlign.Center,
- )
-
- note.replyTo?.forEach {
- NoteCompose(
- it,
- modifier = MaterialTheme.colorScheme.replyModifier,
- unPackReply = false,
- makeItShort = true,
- isQuotedNote = true,
- parentBackgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
-}
-
-@Composable
-fun LoadAddressableNote(
- aTagHex: String,
- accountViewModel: AccountViewModel,
- content: @Composable (AddressableNote?) -> Unit,
-) {
- var note by
- remember(aTagHex) {
- mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex))
- }
-
- if (note == null) {
- LaunchedEffect(key1 = aTagHex) {
- accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote ->
- if (newNote != note) {
- note = newNote
- }
- }
- }
- }
-
- content(note)
-}
-
-@Composable
-fun LoadAddressableNote(
- aTag: ATag,
- accountViewModel: AccountViewModel,
- content: @Composable (AddressableNote?) -> Unit,
-) {
- var note by
- remember(aTag) {
- mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag()))
- }
-
- if (note == null) {
- LaunchedEffect(key1 = aTag) {
- accountViewModel.getOrCreateAddressableNote(aTag) { newNote ->
- if (newNote != note) {
- note = newNote
- }
- }
- }
- }
-
- content(note)
-}
-
-@Composable
-public fun RenderEmojiPack(
- baseNote: Note,
- actionable: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- onClick: ((EmojiUrl) -> Unit)? = null,
-) {
- val noteEvent by
- baseNote
- .live()
- .metadata
- .map { it.note.event }
- .distinctUntilChanged()
- .observeAsState(baseNote.event)
-
- if (noteEvent == null || noteEvent !is EmojiPackEvent) return
-
- (noteEvent as? EmojiPackEvent)?.let {
- RenderEmojiPack(
- noteEvent = it,
- baseNote = baseNote,
- actionable = actionable,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- onClick = onClick,
- )
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-public fun RenderEmojiPack(
- noteEvent: EmojiPackEvent,
- baseNote: Note,
- actionable: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- onClick: ((EmojiUrl) -> Unit)? = null,
-) {
- var expanded by remember { mutableStateOf(false) }
-
- val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() }
-
- val emojisToShow =
- if (expanded) {
- allEmojis
- } else {
- allEmojis.take(60)
- }
-
- Row(verticalAlignment = CenterVertically) {
- Text(
- text = remember(noteEvent) { "#${noteEvent.dTag()}" },
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .weight(1F)
- .padding(5.dp),
- textAlign = TextAlign.Center,
- )
-
- if (actionable) {
- EmojiListOptions(accountViewModel, baseNote)
- }
- }
-
- Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
- FlowRow(modifier = Modifier.padding(top = 5.dp)) {
- emojisToShow.forEach { emoji ->
- if (onClick != null) {
- IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) {
- AsyncImage(
- model = emoji.url,
- contentDescription = null,
- modifier = Size35Modifier,
- )
- }
- } else {
- Box(
- modifier = Size35Modifier,
- contentAlignment = Alignment.Center,
- ) {
- AsyncImage(
- model = emoji.url,
- contentDescription = null,
- modifier = Size35Modifier,
- )
- }
- }
- }
- }
-
- if (allEmojis.size > 60 && !expanded) {
- Row(
- verticalAlignment = CenterVertically,
- horizontalArrangement = Arrangement.Center,
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .background(getGradient(backgroundColor)),
- ) {
- ShowMoreButton { expanded = !expanded }
- }
- }
- }
-}
-
-@Composable
-private fun EmojiListOptions(
- accountViewModel: AccountViewModel,
- emojiPackNote: Note,
-) {
- LoadAddressableNote(
- aTag =
- ATag(
- EmojiPackSelectionEvent.KIND,
- accountViewModel.userProfile().pubkeyHex,
- "",
- null,
- ),
- accountViewModel,
- ) {
- it?.let { usersEmojiList ->
- val hasAddedThis by
- remember {
- usersEmojiList
- .live()
- .metadata
- .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) }
- .distinctUntilChanged()
- }
- .observeAsState()
-
- Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") {
- if (it != true) {
- AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) }
- } else {
- RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) }
- }
- }
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-fun RenderPinListEvent(
- baseNote: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = baseNote.event as? PinListEvent ?: return
-
- val pins by remember { mutableStateOf(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) {
- PinIcon(
- modifier = Size15Modifier,
- tint = MaterialTheme.colorScheme.onBackground.copy(0.32f),
- )
-
- Spacer(modifier = Modifier.width(5.dp))
-
- TranslatableRichTextViewer(
- content = pin,
- canPreview = true,
- tags = EmptyTagList,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
- }
-
- if (pins.size > 3 && !expanded) {
- Row(
- verticalAlignment = CenterVertically,
- horizontalArrangement = Arrangement.Center,
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .background(getGradient(backgroundColor)),
- ) {
- ShowMoreButton { expanded = !expanded }
- }
- }
- }
-}
-
fun getGradient(backgroundColor: MutableState): Brush {
return Brush.verticalGradient(
colors =
@@ -2329,104 +970,6 @@ fun getGradient(backgroundColor: MutableState): Brush {
)
}
-@Composable
-private fun RenderAudioTrack(
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? AudioTrackEvent ?: return
-
- AudioTrackHeader(noteEvent, note, accountViewModel, nav)
-}
-
-@Composable
-private fun RenderAudioHeader(
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? AudioHeaderEvent ?: return
-
- AudioHeader(noteEvent, note, accountViewModel, nav)
-}
-
-@Composable
-private fun RenderLongFormContent(
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? LongTextNoteEvent ?: return
-
- LongFormHeader(noteEvent, note, accountViewModel)
-}
-
-@Composable
-private fun RenderReport(
- note: Note,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? ReportEvent ?: return
-
- val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) }
-
- val reportType =
- base
- .map {
- when (it.reportType) {
- ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
- ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity)
- ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech)
- ReportEvent.ReportType.SPAM -> stringResource(R.string.spam)
- ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation)
- ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior)
- ReportEvent.ReportType.OTHER -> stringResource(R.string.other)
- }
- }
- .toSet()
- .joinToString(", ")
-
- val content =
- remember {
- reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "")
- }
-
- TranslatableRichTextViewer(
- content = content,
- canPreview = true,
- modifier = Modifier,
- tags = EmptyTagList,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
-
- note.replyTo?.lastOrNull()?.let {
- NoteCompose(
- baseNote = it,
- isQuotedNote = true,
- modifier =
- Modifier
- .padding(top = 5.dp)
- .fillMaxWidth()
- .clip(shape = QuoteBorder)
- .border(
- 1.dp,
- MaterialTheme.colorScheme.subtleBorder,
- QuoteBorder,
- ),
- unPackReply = false,
- makeItShort = true,
- parentBackgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-}
-
@Composable
private fun ReplyRow(
note: Note,
@@ -2571,232 +1114,16 @@ fun SecondUserInfoRow(
}
}
-@Composable
-fun DisplayOts(
- note: Note,
- accountViewModel: AccountViewModel,
-) {
- LoadOts(
- note,
- accountViewModel,
- whenConfirmed = { unixtimestamp ->
- val context = LocalContext.current
- val timeStr by remember(unixtimestamp) { mutableStateOf(timeAgoNoDot(unixtimestamp, context = context)) }
-
- ClickableText(
- text = buildAnnotatedString { append(stringResource(id = R.string.existed_since, timeStr)) },
- onClick = {
- val fullDateTime =
- SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000))
-
- accountViewModel.toast(
- context.getString(R.string.ots_info_title),
- context.getString(R.string.ots_info_description, fullDateTime),
- )
- },
- style =
- LocalTextStyle.current.copy(
- color = MaterialTheme.colorScheme.lessImportantLink,
- fontSize = Font14SP,
- fontWeight = FontWeight.Bold,
- ),
- maxLines = 1,
- )
- },
- whenPending = {
- Text(
- stringResource(id = R.string.timestamp_pending_short),
- color = MaterialTheme.colorScheme.lessImportantLink,
- fontSize = Font14SP,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- )
- },
- )
-}
-
-@Composable
-private fun ShowForkInformation(
- noteEvent: BaseTextNoteEvent,
- modifier: Modifier,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() }
- val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() }
- if (forkedAddress != null) {
- LoadAddressableNote(aTag = forkedAddress, accountViewModel = accountViewModel) { addressableNote ->
- if (addressableNote != null) {
- ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav)
- }
- }
- } else if (forkedEvent != null) {
- LoadNote(forkedEvent, accountViewModel = accountViewModel) { event ->
- if (event != null) {
- ForkInformationRowLightColor(event, modifier, accountViewModel, nav)
- }
- }
- }
-}
-
-@Composable
-fun ForkInformationRowLightColor(
- originalVersion: Note,
- modifier: Modifier = Modifier,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteState by originalVersion.live().metadata.observeAsState()
- val note = noteState?.note ?: return
- val author = note.author ?: return
- val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
-
- if (route != null) {
- Row(modifier) {
- ClickableText(
- text =
- buildAnnotatedString {
- append(stringResource(id = R.string.forked_from))
- append(" ")
- },
- onClick = { nav(route) },
- style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
- maxLines = 1,
- overflow = TextOverflow.Visible,
- )
-
- val userState by author.live().metadata.observeAsState()
- val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
- val userTags =
- remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
-
- if (userDisplayName != null) {
- CreateClickableTextWithEmoji(
- clickablePart = userDisplayName,
- maxLines = 1,
- route = route,
- overrideColor = MaterialTheme.colorScheme.nip05,
- fontSize = Font14SP,
- nav = nav,
- tags = userTags,
- )
- }
- }
- }
-}
-
-@Composable
-fun LoadStatuses(
- user: User,
- accountViewModel: AccountViewModel,
- content: @Composable (ImmutableList) -> Unit,
-) {
- var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) }
-
- val userStatus by user.live().statuses.observeAsState()
-
- LaunchedEffect(key1 = userStatus) {
- accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
- if (!equalImmutableLists(statuses, newStatuses)) {
- statuses = newStatuses
- }
- }
- }
-
- content(statuses)
-}
-
-@Composable
-fun LoadOts(
- note: Note,
- accountViewModel: AccountViewModel,
- whenConfirmed: @Composable (Long) -> Unit,
- whenPending: @Composable () -> Unit,
-) {
- var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) }
-
- val noteStatus by note.live().innerOts.observeAsState()
-
- LaunchedEffect(key1 = noteStatus) {
- accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts ->
- earliestDate =
- if (newOts == null) {
- GenericLoadable.Empty()
- } else {
- GenericLoadable.Loaded(newOts)
- }
- }
- }
-
- (earliestDate as? GenericLoadable.Loaded)?.let {
- whenConfirmed(it.loaded)
- } ?: run {
- val account = accountViewModel.account.saveable.observeAsState()
- if (account.value?.account?.hasPendingAttestations(note) == true) {
- whenPending()
- }
- }
-}
-
-@Composable
-fun LoadCityName(
- geohash: GeoHash,
- content: @Composable (String) -> Unit,
-) {
- val context = LocalContext.current
- var cityName by remember(geohash) { mutableStateOf(geohash.toString()) }
-
- LaunchedEffect(key1 = geohash) {
- launch(Dispatchers.IO) {
- val newCityName =
- ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null }
- if (newCityName != null && newCityName != cityName) {
- cityName = newCityName
- }
- }
- }
-
- content(cityName)
-}
-
-@Composable
-fun DisplayLocation(
- geohashStr: String,
- nav: (String) -> Unit,
-) {
- val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull()
- if (geoHash != null) {
- LoadCityName(geoHash) { cityName ->
- ClickableText(
- text = AnnotatedString(cityName),
- onClick = { nav("Geohash/$geoHash") },
- style =
- LocalTextStyle.current.copy(
- color =
- MaterialTheme.colorScheme.primary.copy(
- alpha = 0.52f,
- ),
- fontSize = Font14SP,
- fontWeight = FontWeight.Bold,
- ),
- maxLines = 1,
- )
- }
- }
-}
-
@Composable
fun FirstUserInfoRow(
baseNote: Note,
showAuthorPicture: Boolean,
+ editState: State>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) {
- val isRepost by
- remember(baseNote) {
- derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent }
- }
+ Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
+ val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
val isCommunityPost by
remember(baseNote) {
@@ -2810,9 +1137,9 @@ fun FirstUserInfoRow(
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(HalfPadding)
- NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
+ NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor)
} else {
- NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
+ NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor)
}
if (isRepost) {
@@ -2823,61 +1150,68 @@ fun FirstUserInfoRow(
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
+ if (editState.value is GenericLoadable.Loaded) {
+ (editState.value as? GenericLoadable.Loaded)?.loaded?.let {
+ DisplayEditStatus(it)
+ }
+ }
+
TimeAgo(baseNote)
- MoreOptionsButton(baseNote, accountViewModel)
+ MoreOptionsButton(baseNote, editState, accountViewModel, nav)
}
}
@Composable
-private fun BoostedMark() {
- Text(
- stringResource(id = R.string.boosted),
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 1,
- modifier = HalfStartPadding,
- )
-}
-
-@Composable
-fun MoreOptionsButton(
+fun observeEdits(
baseNote: Note,
accountViewModel: AccountViewModel,
-) {
- val popupExpanded = remember { mutableStateOf(false) }
- val enablePopup = remember { { popupExpanded.value = true } }
-
- IconButton(
- modifier = Size24Modifier,
- onClick = enablePopup,
- ) {
- VerticalDotsIcon(R.string.note_options)
-
- NoteDropDownMenu(
- baseNote,
- popupExpanded,
- accountViewModel,
- )
+): State> {
+ if (baseNote.event !is TextNoteEvent) {
+ return remember { mutableStateOf(GenericLoadable.Empty()) }
}
-}
-@Composable
-fun TimeAgo(note: Note) {
- val time = remember(note) { note.createdAt() } ?: return
- TimeAgo(time)
-}
+ val editState =
+ remember(baseNote.idHex) {
+ val cached = accountViewModel.cachedModificationEventsForNote(baseNote)
+ mutableStateOf(
+ if (cached != null) {
+ if (cached.isEmpty()) {
+ GenericLoadable.Empty()
+ } else {
+ val state = EditState()
+ state.updateModifications(cached)
+ GenericLoadable.Loaded(state)
+ }
+ } else {
+ GenericLoadable.Loading()
+ },
+ )
+ }
-@Composable
-fun TimeAgo(time: Long) {
- val context = LocalContext.current
- val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
+ val updatedNote by baseNote.live().innerModifications.observeAsState()
- Text(
- text = timeStr,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 1,
- )
+ LaunchedEffect(key1 = updatedNote) {
+ updatedNote?.note?.let {
+ accountViewModel.findModificationEventsForNote(it) { newModifications ->
+ if (newModifications.isEmpty()) {
+ if (editState.value !is GenericLoadable.Empty) {
+ editState.value = GenericLoadable.Empty()
+ }
+ } else {
+ if (editState.value is GenericLoadable.Loaded) {
+ (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications)
+ } else {
+ val state = EditState()
+ state.updateModifications(newModifications)
+ editState.value = GenericLoadable.Loaded(state)
+ }
+ }
+ }
+ }
+ }
+
+ return editState
}
@Composable
@@ -2939,28 +1273,6 @@ private fun RenderAuthorImages(
}
}
-@Composable
-fun LoadChannel(
- baseChannelHex: String,
- accountViewModel: AccountViewModel,
- content: @Composable (Channel) -> Unit,
-) {
- var channel by
- remember(baseChannelHex) {
- mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex))
- }
-
- if (channel == null) {
- LaunchedEffect(key1 = baseChannelHex) {
- accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
- launch(Dispatchers.Main) { channel = newChannel }
- }
- }
- }
-
- channel?.let { content(it) }
-}
-
@Composable
private fun ChannelNotePicture(
baseChannel: Channel,
@@ -3006,1580 +1318,3 @@ private fun RepostNoteAuthorPicture(
},
)
}
-
-@Composable
-fun DisplayHighlight(
- highlight: String,
- authorHex: String?,
- url: String?,
- postAddress: ATag?,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val quote =
- remember {
- highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" }
- }
-
- TranslatableRichTextViewer(
- quote,
- canPreview = canPreview && !makeItShort,
- remember { Modifier.fillMaxWidth() },
- EmptyTagList,
- backgroundColor,
- accountViewModel,
- nav,
- )
-
- DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun DisplayQuoteAuthor(
- authorHex: String,
- url: String?,
- postAddress: ATag?,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) }
-
- if (userBase == null) {
- LaunchedEffect(Unit) {
- accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase }
- }
- }
-
- val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current)
-
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(spaceWidth),
- verticalArrangement = Arrangement.Center,
- ) {
- userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) }
-
- url?.let { url -> LoadAndDisplayUrl(url) }
-
- postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) }
- }
-}
-
-@Composable
-private fun LoadAndDisplayPost(
- postAddress: ATag,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- LoadAddressableNote(aTag = postAddress, accountViewModel) {
- it?.let { note ->
- val noteEvent by
- note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event)
-
- val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() }
-
- title?.let {
- Text(remember { "-" }, maxLines = 1)
- ClickableText(
- text = AnnotatedString(title),
- onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } },
- style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
- )
- }
- }
- }
-}
-
-@Composable
-private fun LoadAndDisplayUrl(url: String) {
- val validatedUrl =
- remember {
- try {
- URL(url)
- } catch (e: Exception) {
- Log.w("Note Compose", "Invalid URI: $url")
- null
- }
- }
-
- validatedUrl?.host?.let { host ->
- Text(remember { "-" }, maxLines = 1)
- ClickableUrl(urlText = host, url = url)
- }
-}
-
-@Composable
-fun LoadAndDisplayUser(
- userBase: User,
- nav: (String) -> Unit,
-) {
- LoadAndDisplayUser(userBase, "User/${userBase.pubkeyHex}", nav)
-}
-
-@Composable
-fun LoadAndDisplayUser(
- userBase: User,
- route: String,
- nav: (String) -> Unit,
-) {
- val userState by userBase.live().metadata.observeAsState()
- val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
- val userTags =
- remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
-
- if (userDisplayName != null) {
- CreateClickableTextWithEmoji(
- clickablePart = userDisplayName,
- maxLines = 1,
- route = route,
- nav = nav,
- tags = userTags,
- )
- }
-}
-
-@Composable
-fun BadgeDisplay(baseNote: Note) {
- val background = MaterialTheme.colorScheme.background
- val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
-
- val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() }
- val name = remember { badgeData.name() }
- val description = remember { badgeData.description() }
-
- var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) }
- var imageResult by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = imageResult) {
- launch(Dispatchers.IO) {
- imageResult?.let {
- val backgroundColor =
- it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
- val colorFromImage = Color(backgroundColor)
- val textBackground =
- if (colorFromImage.luminance() > 0.5) {
- lightColorScheme().onBackground
- } else {
- darkColorScheme().onBackground
- }
-
- launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) }
- }
- }
- }
-
- Row(
- modifier =
- Modifier
- .padding(10.dp)
- .clip(shape = CutCornerShape(20, 20, 20, 20))
- .border(
- 5.dp,
- MaterialTheme.colorScheme.mediumImportanceLink,
- CutCornerShape(20),
- )
- .background(backgroundFromImage.first),
- ) {
- RenderBadge(
- image,
- name,
- backgroundFromImage.second,
- description,
- ) {
- if (imageResult == null) {
- imageResult = it.result
- }
- }
- }
-}
-
-@Composable
-private fun RenderBadge(
- image: String?,
- name: String?,
- backgroundFromImage: Color,
- description: String?,
- onSuccess: (AsyncImagePainter.State.Success) -> Unit,
-) {
- Column {
- image.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.badge_award_image_for,
- name ?: "",
- ),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxWidth(),
- onSuccess = onSuccess,
- )
- }
-
- name?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp),
- color = backgroundFromImage,
- )
- }
-
- description?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
- color = Color.Gray,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
-}
-
-@Composable
-fun FileHeaderDisplay(
- note: Note,
- roundedCorner: Boolean,
- accountViewModel: AccountViewModel,
-) {
- val event = (note.event as? FileHeaderEvent) ?: return
- val fullUrl = event.url() ?: return
-
- val content by
- remember(note) {
- val blurHash = event.blurhash()
- val hash = event.hash()
- val dimensions = event.dimensions()
- val description = event.alt() ?: event.content
- val isImage = RichTextParser.isImageUrl(fullUrl)
- val uri = note.toNostrUri()
-
- mutableStateOf(
- if (isImage) {
- MediaUrlImage(
- url = fullUrl,
- description = description,
- hash = hash,
- blurhash = blurHash,
- dim = dimensions,
- uri = uri,
- )
- } else {
- MediaUrlVideo(
- url = fullUrl,
- description = description,
- hash = hash,
- dim = dimensions,
- uri = uri,
- authorName = note.author?.toBestDisplayName(),
- )
- },
- )
- }
-
- SensitivityWarning(note = note, accountViewModel = accountViewModel) {
- ZoomableContentView(
- content = content,
- roundedCorner = roundedCorner,
- accountViewModel = accountViewModel,
- )
- }
-}
-
-@Composable
-fun VideoDisplay(
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val event = (note.event as? VideoEvent) ?: return
- val fullUrl = event.url() ?: return
-
- val title = event.title()
- val summary = event.content.ifBlank { null }?.takeIf { title != it }
- val image = event.thumb() ?: event.image()
- val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be")
- val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- val content by
- remember(note) {
- val blurHash = event.blurhash()
- val hash = event.hash()
- val dimensions = event.dimensions()
- val description = event.alt() ?: event.content
- val isImage = RichTextParser.isImageUrl(fullUrl)
- val uri = note.toNostrUri()
-
- mutableStateOf(
- if (isImage) {
- MediaUrlImage(
- url = fullUrl,
- description = description,
- hash = hash,
- blurhash = blurHash,
- dim = dimensions,
- uri = uri,
- )
- } else {
- MediaUrlVideo(
- url = fullUrl,
- description = description,
- hash = hash,
- dim = dimensions,
- uri = uri,
- authorName = note.author?.toBestDisplayName(),
- artworkUri = event.thumb() ?: event.image(),
- )
- },
- )
- }
-
- SensitivityWarning(note = note, accountViewModel = accountViewModel) {
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 5.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- if (isYouTube) {
- val uri = LocalUriHandler.current
- Row(
- modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } },
- ) {
- image?.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.preview_card_image_for,
- it,
- ),
- contentScale = ContentScale.FillWidth,
- modifier = MaterialTheme.colorScheme.imageModifier,
- )
- }
- ?: CreateImageHeader(note, accountViewModel)
- }
- } else {
- ZoomableContentView(
- content = content,
- roundedCorner = true,
- accountViewModel = accountViewModel,
- )
- }
-
- title?.let {
- Text(
- text = it,
- fontWeight = FontWeight.Bold,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 5.dp),
- )
- }
-
- summary?.let {
- TranslatableRichTextViewer(
- content = it,
- canPreview = canPreview && !makeItShort,
- modifier = Modifier.fillMaxWidth(),
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- if (event.hasHashtags()) {
- Row(
- Modifier.fillMaxWidth(),
- ) {
- DisplayUncitedHashtags(
- remember(event) { event.hashtags().toImmutableList() },
- summary ?: "",
- nav,
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun FileStorageHeaderDisplay(
- baseNote: Note,
- roundedCorner: Boolean,
- accountViewModel: AccountViewModel,
-) {
- val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
- val dataEventId = eventHeader.dataEventId() ?: return
-
- LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
- if (contentNote != null) {
- ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
- }
- }
-}
-
-@Composable
-private fun ObserverAndRenderNIP95(
- header: Note,
- content: Note,
- roundedCorner: Boolean,
- accountViewModel: AccountViewModel,
-) {
- val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
-
- val appContext = LocalContext.current.applicationContext
-
- val noteState by content.live().metadata.observeAsState()
-
- val content by
- remember(noteState) {
- // Creates a new object when the event arrives to force an update of the image.
- val note = noteState?.note
- val uri = header.toNostrUri()
- val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) }
- val blurHash = eventHeader.blurhash()
- val dimensions = eventHeader.dimensions()
- val description = eventHeader.alt() ?: eventHeader.content
- val mimeType = eventHeader.mimeType()
-
- val newContent =
- if (mimeType?.startsWith("image") == true) {
- MediaLocalImage(
- localFile = localDir,
- mimeType = mimeType,
- description = description,
- dim = dimensions,
- blurhash = blurHash,
- isVerified = true,
- uri = uri,
- )
- } else {
- MediaLocalVideo(
- localFile = localDir,
- mimeType = mimeType,
- description = description,
- dim = dimensions,
- isVerified = true,
- uri = uri,
- authorName = header.author?.toBestDisplayName(),
- )
- }
-
- mutableStateOf(newContent)
- }
-
- Crossfade(targetState = content) {
- if (it != null) {
- SensitivityWarning(note = header, accountViewModel = accountViewModel) {
- ZoomableContentView(
- content = it,
- roundedCorner = roundedCorner,
- accountViewModel = accountViewModel,
- )
- }
- }
- }
-}
-
-@Composable
-fun AudioTrackHeader(
- noteEvent: AudioTrackEvent,
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- 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>>(emptyList()) }
-
- LaunchedEffect(key1 = participants) {
- accountViewModel.loadParticipants(participants) { participantUsers = it }
- }
-
- Row(modifier = Modifier.padding(top = 5.dp)) {
- Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- Row {
- subject?.let {
- Row(
- verticalAlignment = 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 = CenterVertically,
- modifier =
- Modifier
- .padding(top = 5.dp, start = 10.dp, end = 10.dp)
- .clickable {
- nav("User/${it.second.pubkeyHex}")
- },
- ) {
- ClickableUserPicture(it.second, 25.dp, accountViewModel)
- 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.colorScheme.placeholderText,
- maxLines = 1,
- )
- }
- }
- }
-
- media?.let { media ->
- Row(
- verticalAlignment = CenterVertically,
- ) {
- cover?.let { cover ->
- LoadThumbAndThenVideoView(
- videoUri = media,
- title = noteEvent.subject(),
- thumbUri = cover,
- authorName = note.author?.toBestDisplayName(),
- roundedCorner = true,
- nostrUriCallback = "nostr:${note.toNEvent()}",
- accountViewModel = accountViewModel,
- )
- }
- ?: VideoView(
- videoUri = media,
- title = noteEvent.subject(),
- authorName = note.author?.toBestDisplayName(),
- roundedCorner = true,
- accountViewModel = accountViewModel,
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun AudioHeader(
- noteEvent: AudioHeaderEvent,
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val media = remember { noteEvent.stream() ?: noteEvent.download() }
- val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } }
- val content = remember { noteEvent.content().ifBlank { null } }
-
- val defaultBackground = MaterialTheme.colorScheme.background
- val background = remember { mutableStateOf(defaultBackground) }
- val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- Row(modifier = Modifier.padding(top = 5.dp)) {
- Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- media?.let { media ->
- Row(
- verticalAlignment = CenterVertically,
- ) {
- VideoView(
- videoUri = media,
- waveform = waveform,
- title = noteEvent.subject(),
- authorName = note.author?.toBestDisplayName(),
- roundedCorner = true,
- accountViewModel = accountViewModel,
- nostrUriCallback = note.toNostrUri(),
- )
- }
- }
-
- content?.let {
- Row(
- verticalAlignment = CenterVertically,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 5.dp),
- ) {
- TranslatableRichTextViewer(
- content = it,
- canPreview = true,
- tags = tags,
- backgroundColor = background,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- }
-
- if (noteEvent.hasHashtags()) {
- Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
- val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() }
- DisplayUncitedHashtags(hashtags, content ?: "", nav)
- }
- }
- }
- }
-}
-
-@Composable
-fun RenderGitPatchEvent(
- baseNote: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val event = baseNote.event as? GitPatchEvent ?: return
-
- RenderGitPatchEvent(event, baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
-}
-
-@Composable
-private fun RenderShortRepositoryHeader(
- baseNote: AddressableNote,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteState = baseNote.live().metadata.observeAsState()
- val note = remember(noteState) { noteState.value?.note } ?: return
- val noteEvent = note.event as? GitRepositoryEvent ?: return
-
- Column(
- modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
- ) {
- val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() }
- Text(
- text = stringResource(id = R.string.git_repository, title),
- style = MaterialTheme.typography.titleLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(),
- )
-
- noteEvent.description()?.let {
- Spacer(modifier = DoubleVertSpacer)
- Text(
- text = it,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
-}
-
-@Composable
-private fun RenderGitPatchEvent(
- noteEvent: GitPatchEvent,
- note: Note,
- makeItShort: Boolean,
- canPreview: Boolean,
- backgroundColor: MutableState,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val repository = remember(noteEvent) { noteEvent.repository() }
-
- if (repository != null) {
- LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) {
- if (it != null) {
- RenderShortRepositoryHeader(it, accountViewModel, nav)
- Spacer(modifier = DoubleVertSpacer)
- }
- }
- }
-
- LoadDecryptedContent(note, accountViewModel) { body ->
- val eventContent by
- remember(note.event) {
- derivedStateOf {
- val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
-
- if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
- "### $subject\n$body"
- } else {
- body
- }
- }
- }
-
- val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) }
-
- if (makeItShort && isAuthorTheLoggedUser) {
- Text(
- text = eventContent,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- } else {
- SensitivityWarning(
- note = note,
- accountViewModel = accountViewModel,
- ) {
- val modifier = remember(note) { Modifier.fillMaxWidth() }
- val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
-
- TranslatableRichTextViewer(
- content = eventContent,
- canPreview = canPreview && !makeItShort,
- modifier = modifier,
- tags = tags,
- backgroundColor = backgroundColor,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
-
- if (note.event?.hasHashtags() == true) {
- val hashtags =
- remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
- DisplayUncitedHashtags(hashtags, eventContent, nav)
- }
- }
- }
-}
-
-@Composable
-fun RenderGitRepositoryEvent(
- baseNote: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val event = baseNote.event as? GitRepositoryEvent ?: return
-
- RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav)
-}
-
-@Composable
-private fun RenderGitRepositoryEvent(
- noteEvent: GitRepositoryEvent,
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() }
- val summary = remember(noteEvent) { noteEvent.description() }
- val web = remember(noteEvent) { noteEvent.web() }
- val clone = remember(noteEvent) { noteEvent.clone() }
-
- Row(
- modifier =
- Modifier
- .clip(shape = QuoteBorder)
- .border(
- 1.dp,
- MaterialTheme.colorScheme.subtleBorder,
- QuoteBorder,
- ).padding(Size10dp),
- ) {
- Column {
- Text(
- text = stringResource(id = R.string.git_repository, title),
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(),
- )
-
- summary?.let {
- Text(
- text = it,
- modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp),
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
- }
-
- HorizontalDivider(thickness = DividerThickness)
-
- web?.let {
- Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
- Text(
- text = stringResource(id = R.string.git_web_address),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Spacer(modifier = StdHorzSpacer)
- ClickableUrl(
- url = it,
- urlText = it.removePrefix("https://").removePrefix("http://"),
- )
- }
- }
-
- clone?.let {
- Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
- Text(
- text = stringResource(id = R.string.git_clone_address),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Spacer(modifier = StdHorzSpacer)
- ClickableUrl(
- url = it,
- urlText = it.removePrefix("https://").removePrefix("http://"),
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun RenderLiveActivityEvent(
- baseNote: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- Row(modifier = Modifier.padding(top = 5.dp)) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav)
- }
- }
-}
-
-@Composable
-fun RenderLiveActivityEventInner(
- baseNote: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
-
- val eventUpdates by baseNote.live().metadata.observeAsState()
-
- val media = remember(eventUpdates) { noteEvent.streaming() }
- val cover = remember(eventUpdates) { noteEvent.image() }
- val subject = remember(eventUpdates) { noteEvent.title() }
- val content = remember(eventUpdates) { noteEvent.summary() }
- val participants = remember(eventUpdates) { noteEvent.participants() }
- val status = remember(eventUpdates) { noteEvent.status() }
- val starts = remember(eventUpdates) { noteEvent.starts() }
-
- Row(
- verticalAlignment = CenterVertically,
- modifier =
- Modifier
- .padding(vertical = 5.dp)
- .fillMaxWidth(),
- ) {
- subject?.let {
- Text(
- text = it,
- fontWeight = FontWeight.Bold,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- }
-
- Spacer(modifier = StdHorzSpacer)
-
- Crossfade(targetState = status, label = "RenderLiveActivityEventInner") {
- when (it) {
- STATUS_LIVE -> {
- media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } }
- }
- STATUS_PLANNED -> {
- ScheduledFlag(starts)
- }
- }
- }
- }
-
- var participantUsers by remember {
- mutableStateOf>>(
- persistentListOf(),
- )
- }
-
- LaunchedEffect(key1 = eventUpdates) {
- accountViewModel.loadParticipants(participants) { newParticipantUsers ->
- if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
- participantUsers = newParticipantUsers
- }
- }
- }
-
- media?.let { media ->
- if (status == STATUS_LIVE) {
- CheckIfUrlIsOnline(media, accountViewModel) { isOnline ->
- if (isOnline) {
- Row(
- verticalAlignment = CenterVertically,
- ) {
- VideoView(
- videoUri = media,
- title = subject,
- artworkUri = cover,
- authorName = baseNote.author?.toBestDisplayName(),
- roundedCorner = true,
- accountViewModel = accountViewModel,
- nostrUriCallback = "nostr:${baseNote.toNEvent()}",
- )
- }
- } else {
- Row(
- verticalAlignment = CenterVertically,
- modifier =
- Modifier
- .padding(10.dp)
- .height(100.dp),
- ) {
- Text(
- text = stringResource(id = R.string.live_stream_is_offline),
- color = MaterialTheme.colorScheme.onBackground,
- fontWeight = FontWeight.Bold,
- )
- }
- }
- }
- } else if (status == STATUS_ENDED) {
- Row(
- verticalAlignment = CenterVertically,
- modifier =
- Modifier
- .padding(10.dp)
- .height(100.dp),
- ) {
- Text(
- text = stringResource(id = R.string.live_stream_has_ended),
- color = MaterialTheme.colorScheme.onBackground,
- fontWeight = FontWeight.Bold,
- )
- }
- }
- }
-
- participantUsers.forEach {
- Row(
- verticalAlignment = CenterVertically,
- modifier =
- Modifier
- .padding(vertical = 5.dp)
- .clickable { nav("User/${it.second.pubkeyHex}") },
- ) {
- ClickableUserPicture(it.second, 25.dp, accountViewModel)
- Spacer(StdHorzSpacer)
- UsernameDisplay(it.second, Modifier.weight(1f))
- Spacer(StdHorzSpacer)
- it.first.role?.let {
- Text(
- text = it.capitalize(Locale.ROOT),
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 1,
- )
- }
- }
- }
-}
-
-@Composable
-private fun LongFormHeader(
- noteEvent: LongTextNoteEvent,
- note: Note,
- accountViewModel: AccountViewModel,
-) {
- val image = remember(noteEvent) { noteEvent.image() }
- val title = remember(noteEvent) { noteEvent.title() }
- val summary =
- remember(noteEvent) {
- noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
- }
-
- Row(
- modifier =
- Modifier
- .padding(top = Size5dp)
- .clip(shape = QuoteBorder)
- .border(
- 1.dp,
- MaterialTheme.colorScheme.subtleBorder,
- QuoteBorder,
- ),
- ) {
- Column {
- val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value }
-
- if (automaticallyShowUrlPreview) {
- image?.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.preview_card_image_for,
- it,
- ),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxWidth(),
- )
- }
- ?: CreateImageHeader(note, accountViewModel)
- }
-
- title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyLarge,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, top = 10.dp),
- )
- }
-
- summary?.let {
- Spacer(modifier = StdVertSpacer)
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
- color = Color.Gray,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
- }
-}
-
-@Composable
-private fun RenderWikiContent(
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteEvent = note.event as? WikiNoteEvent ?: return
-
- WikiNoteHeader(noteEvent, note, accountViewModel, nav)
-}
-
-@Composable
-private fun WikiNoteHeader(
- noteEvent: WikiNoteEvent,
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val title = remember(noteEvent) { noteEvent.title() }
- val summary =
- remember(noteEvent) {
- noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
- }
- val image = remember(noteEvent) { noteEvent.image() }
-
- Row(
- modifier =
- Modifier
- .padding(top = Size5dp)
- .clip(shape = QuoteBorder)
- .border(
- 1.dp,
- MaterialTheme.colorScheme.subtleBorder,
- QuoteBorder,
- ),
- ) {
- Column {
- val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value }
-
- if (automaticallyShowUrlPreview) {
- image?.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.preview_card_image_for,
- it,
- ),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxWidth(),
- )
- }
- ?: CreateImageHeader(note, accountViewModel)
- }
-
- title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyLarge,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, top = 10.dp),
- )
- }
-
- summary?.let {
- Spacer(modifier = StdVertSpacer)
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
- color = Color.Gray,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
- }
-}
-
-@Composable
-private fun RenderClassifieds(
- noteEvent: ClassifiedsEvent,
- note: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val image = remember(noteEvent) { noteEvent.image() }
- val title = remember(noteEvent) { noteEvent.title() }
- val summary =
- remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
- val price = remember(noteEvent) { noteEvent.price() }
- val location = remember(noteEvent) { noteEvent.location() }
-
- Row(
- modifier =
- Modifier
- .clip(shape = QuoteBorder)
- .border(
- 1.dp,
- MaterialTheme.colorScheme.subtleBorder,
- QuoteBorder,
- ),
- ) {
- Column {
- Row {
- image?.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.preview_card_image_for,
- it,
- ),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxWidth(),
- )
- }
- ?: CreateImageHeader(note, accountViewModel)
- }
-
- Row(
- Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp),
- verticalAlignment = CenterVertically,
- ) {
- title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- }
-
- price?.let {
- val priceTag =
- remember(noteEvent) {
- val newAmount =
- price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount
-
- if (price.frequency != null && price.currency != null) {
- "$newAmount ${price.currency}/${price.frequency}"
- } else if (price.currency != null) {
- "$newAmount ${price.currency}"
- } else {
- newAmount
- }
- }
-
- Text(
- text = priceTag,
- maxLines = 1,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Bold,
- modifier =
- remember {
- Modifier
- .clip(SmallBorder)
- .padding(start = 5.dp)
- },
- )
- }
- }
-
- if (summary != null || location != null) {
- Row(
- Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp),
- verticalAlignment = CenterVertically,
- ) {
- summary?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.weight(1f),
- color = Color.Gray,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
- }
-
- /*
- Column {
- location?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = Color.Gray,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(start = 5.dp)
- )
- }
-
- Button(
- modifier = Modifier
- .padding(horizontal = 3.dp)
- .width(50.dp),
- onClick = {
- note.author?.let {
- accountViewModel.createChatRoomFor(it) {
- nav("Room/$it")
- }
- }
- },
- contentPadding = ZeroPadding,
- colors = ButtonDefaults
- .buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- )
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_dm),
- stringResource(R.string.send_a_direct_message),
- modifier = Modifier.size(20.dp),
- tint = Color.White
- )
- }
- }
-
- */
- }
- }
-
- Spacer(modifier = DoubleVertSpacer)
- }
- }
-}
-
-@Composable
-fun CreateImageHeader(
- note: Note,
- accountViewModel: AccountViewModel,
-) {
- val banner = remember(note.author?.info) { note.author?.info?.banner }
-
- Box {
- banner?.let {
- AsyncImage(
- model = it,
- contentDescription =
- stringResource(
- R.string.preview_card_image_for,
- it,
- ),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier.fillMaxWidth(),
- )
- }
- ?: Image(
- painter = painterResource(R.drawable.profile_banner),
- contentDescription = stringResource(R.string.profile_banner),
- contentScale = ContentScale.FillWidth,
- modifier =
- remember {
- Modifier
- .fillMaxWidth()
- .height(150.dp)
- },
- )
-
- Box(
- remember {
- Modifier
- .width(75.dp)
- .height(75.dp)
- .padding(10.dp)
- .align(Alignment.BottomStart)
- },
- ) {
- NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp)
- }
- }
-}
-
-@Preview
-@Composable
-fun RenderEyeGlassesPrescriptionPreview() {
- val accountViewModel = mockAccountViewModel()
- val nav: (String) -> Unit = {}
-
- val prescriptionEvent = Event.fromJson("{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}") as FhirResourceEvent
-
- RenderFhirResource(prescriptionEvent, accountViewModel, nav)
-}
-
-@Composable
-fun RenderFhirResource(
- baseNote: Note,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val event = baseNote.event as? FhirResourceEvent ?: return
-
- RenderFhirResource(event, accountViewModel, nav)
-}
-
-@Composable
-fun RenderFhirResource(
- event: FhirResourceEvent,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- var baseResource: Resource? by remember(event) {
- mutableStateOf(null)
- }
-
- LaunchedEffect(key1 = event) {
- withContext(Dispatchers.IO) {
- val mapper =
- jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
-
- try {
- baseResource = mapper.readValue(event.content, Resource::class.java)
- } catch (e: Exception) {
- Log.e("RenderEyeGlassesPrescription", "Parser error", e)
- }
- }
- }
-
- baseResource?.let { resource ->
- when (resource) {
- is Bundle -> {
- val db = resource.entry.associate { it.id to it }
- val vision = resource.entry.filterIsInstance(VisionPrescription::class.java)
-
- vision.firstOrNull()?.let {
- RenderEyeGlassesPrescription(it, db, accountViewModel, nav)
- }
- }
- is VisionPrescription -> {
- val db = mapOf(resource.id to resource)
- RenderEyeGlassesPrescription(resource, db, accountViewModel, nav)
- }
- else -> {
- }
- }
- }
-}
-
-@Composable
-fun RenderEyeGlassesPrescription(
- visionPrescription: VisionPrescription,
- db: Map,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(Size10dp),
- ) {
- val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" }
- val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" }
-
- Text(
- "Eyeglasses Prescription",
- modifier = Modifier.padding(4.dp).fillMaxWidth(),
- textAlign = TextAlign.Center,
- )
-
- Spacer(StdVertSpacer)
-
- visionPrescription.patient?.reference?.let {
- val patient = findReferenceInDb(it, db) as? Patient
-
- patient?.name?.firstOrNull()?.assembleName()?.let {
- Text(
- text = "Patient: $it",
- modifier = Modifier.padding(4.dp).fillMaxWidth(),
- )
- }
- }
- visionPrescription.status?.let {
- Text(
- text = "Status: ${it.capitalize()}",
- modifier = Modifier.padding(4.dp).fillMaxWidth(),
- )
- }
-
- Spacer(DoubleVertSpacer)
-
- RenderEyeGlassesPrescriptionHeaderRow()
- HorizontalDivider(thickness = DividerThickness)
-
- rightEye?.let {
- RenderEyeGlassesPrescriptionRow(data = it)
- HorizontalDivider(thickness = DividerThickness)
- }
-
- leftEye?.let {
- RenderEyeGlassesPrescriptionRow(data = it)
- HorizontalDivider(thickness = DividerThickness)
- }
-
- Spacer(DoubleVertSpacer)
-
- visionPrescription.prescriber?.reference?.let {
- val practitioner = findReferenceInDb(it, db) as? Practitioner
-
- practitioner?.name?.firstOrNull()?.assembleName()?.let {
- Text(
- text = "Signed by: $it",
- modifier = Modifier.padding(4.dp).fillMaxWidth(),
- textAlign = TextAlign.Right,
- )
- }
- }
- }
-}
-
-@Composable
-fun RenderEyeGlassesPrescriptionHeaderRow() {
- Row(
- modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = "Eye",
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- VerticalDivider(thickness = DividerThickness)
- Text(
- text = "Sph",
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- Text(
- text = "Cyl",
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- Text(
- text = "Axis",
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- VerticalDivider(thickness = DividerThickness)
- Text(
- text = "Add",
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- }
-}
-
-@Composable
-fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) {
- Row(
- modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- val numberFormat = DecimalFormat("##.00")
- val integerFormat = DecimalFormat("###")
-
- Text(
- text = data.eye?.capitalize() ?: "Unknown",
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- VerticalDivider(thickness = DividerThickness)
- Text(
- text = formatOrBlank(data.sphere, numberFormat),
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- Text(
- text = formatOrBlank(data.cylinder, numberFormat),
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- Text(
- text = formatOrBlank(data.axis, integerFormat),
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- VerticalDivider(thickness = DividerThickness)
- Text(
- text = formatOrBlank(data.add, numberFormat),
- textAlign = TextAlign.Right,
- modifier = Modifier.padding(4.dp).weight(1f),
- )
- }
-}
-
-fun formatOrBlank(
- amount: Double?,
- numberFormat: NumberFormat,
-): String {
- if (amount == null) return ""
- if (Math.abs(amount) < 0.01) return ""
- return numberFormat.format(amount)
-}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
index d2bbb7f59..a60b15af4 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -52,11 +51,12 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -128,13 +128,6 @@ val externalLinkForNote = { note: Note ->
}
}
-@Composable
-private fun VerticalDivider(color: Color) =
- Divider(
- color = color,
- modifier = Modifier.fillMaxHeight().width(1.dp),
- )
-
@Composable
fun LongPressToQuickAction(
baseNote: Note,
@@ -263,7 +256,7 @@ private fun RenderMainPopup(
onDismiss()
}
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringResource(R.string.quick_action_copy_user_id),
@@ -274,7 +267,7 @@ private fun RenderMainPopup(
onDismiss()
}
}
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.FormatQuote,
stringResource(R.string.quick_action_copy_note_id),
@@ -287,7 +280,7 @@ private fun RenderMainPopup(
}
if (!isOwnNote) {
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.Block,
@@ -302,9 +295,8 @@ private fun RenderMainPopup(
}
}
}
- Divider(
+ HorizontalDivider(
color = primaryLight,
- modifier = Modifier.fillMaxWidth().width(1.dp),
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
@@ -337,7 +329,7 @@ private fun RenderMainPopup(
}
}
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast),
@@ -346,7 +338,7 @@ private fun RenderMainPopup(
// showSelectTextDialog = true
onDismiss()
}
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringResource(R.string.quick_action_share),
@@ -375,7 +367,7 @@ private fun RenderMainPopup(
}
if (!isOwnNote) {
- VerticalDivider(primaryLight)
+ VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.Report,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt
index e40d7ac40..5b3d75f7c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt
@@ -68,7 +68,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -101,7 +100,7 @@ fun PollNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel")
+ val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}")
pollViewModel.load(accountViewModel.account, baseNote)
@@ -126,11 +125,9 @@ fun PollNote(
) {
WatchZapsAndUpdateTallies(baseNote, pollViewModel)
- val tallies by pollViewModel.tallies.collectAsStateWithLifecycle()
-
- tallies.forEach { poll_op ->
+ pollViewModel.tallies.forEach { option ->
OptionNote(
- poll_op,
+ option,
pollViewModel,
baseNote,
accountViewModel,
@@ -167,9 +164,9 @@ private fun OptionNote(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp),
) {
- if (!pollViewModel.canZap()) {
+ if (!pollViewModel.canZap.value) {
val color =
- if (poolOption.consensusThreadhold) {
+ if (poolOption.consensusThreadhold.value) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colorScheme.mediumImportanceLink
@@ -181,8 +178,7 @@ private fun OptionNote(
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
- poolOption.descriptor,
- poolOption.tally.toFloat(),
+ poolOption,
color,
canPreview,
tags,
@@ -220,8 +216,7 @@ private fun OptionNote(
@Composable
private fun RenderOptionAfterVote(
- description: String,
- totalRatio: Float,
+ poolOption: PollOption,
color: Color,
canPreview: Boolean,
tags: ImmutableListOfLists,
@@ -229,10 +224,9 @@ private fun RenderOptionAfterVote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" }
-
Box(
- Modifier.fillMaxWidth(0.75f)
+ Modifier
+ .fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.border(
2.dp,
@@ -243,7 +237,9 @@ private fun RenderOptionAfterVote(
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
- progress = totalRatio,
+ progress = {
+ poolOption.tally.value.toFloat()
+ },
)
Row(
@@ -251,23 +247,34 @@ private fun RenderOptionAfterVote(
) {
Column(
horizontalAlignment = Alignment.End,
- modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) },
+ modifier =
+ remember {
+ Modifier
+ .padding(horizontal = 10.dp)
+ .width(45.dp)
+ },
) {
Text(
- text = totalPercentage,
+ text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold,
)
}
Column(
- modifier = remember { Modifier.fillMaxWidth().padding(15.dp) },
+ modifier =
+ remember {
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 15.dp, horizontal = 10.dp)
+ },
) {
TranslatableRichTextViewer(
- description,
+ poolOption.descriptor,
canPreview,
- remember { Modifier },
+ Modifier,
tags,
backgroundColor,
+ poolOption.descriptor,
accountViewModel,
nav,
)
@@ -286,7 +293,8 @@ private fun RenderOptionBeforeVote(
nav: (String) -> Unit,
) {
Box(
- Modifier.fillMaxWidth(0.75f)
+ Modifier
+ .fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.border(
2.dp,
@@ -300,6 +308,7 @@ private fun RenderOptionBeforeVote(
remember { Modifier.padding(15.dp) },
tags,
backgroundColor,
+ id = description,
accountViewModel,
nav,
)
@@ -358,7 +367,7 @@ fun ZapVote(
R.string.poll_unable_to_vote,
R.string.poll_author_no_vote,
)
- } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
+ } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn.value) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
accountViewModel.toast(
R.string.poll_unable_to_vote,
@@ -443,7 +452,7 @@ fun ZapVote(
clickablePrepend()
- if (poolOption.zappedByLoggedIn) {
+ if (poolOption.zappedByLoggedIn.value) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -471,8 +480,8 @@ fun ZapVote(
}
// only show tallies after a user has zapped note
- if (!pollViewModel.canZap()) {
- val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) }
+ if (!pollViewModel.canZap.value) {
+ val amountStr = remember(poolOption.zappedValue.value) { showAmount(poolOption.zappedValue.value) }
Text(
text = amountStr,
fontSize = Font14SP,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt
index 0fb6008fa..5f4949bc8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt
@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.note
-import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -36,20 +37,18 @@ import com.vitorpamplona.quartz.events.VALUE_MAXIMUM
import com.vitorpamplona.quartz.events.VALUE_MINIMUM
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
-@Immutable
+@Stable
data class PollOption(
val option: Int,
val descriptor: String,
- val zappedValue: BigDecimal,
- val tally: BigDecimal,
- val consensusThreadhold: Boolean,
- val zappedByLoggedIn: Boolean,
+ var zappedValue: MutableState = mutableStateOf(BigDecimal.ZERO),
+ var tally: MutableState = mutableStateOf(BigDecimal.ZERO),
+ var consensusThreadhold: MutableState = mutableStateOf(false),
+ var zappedByLoggedIn: MutableState = mutableStateOf(false),
)
@Stable
@@ -70,65 +69,67 @@ class PollNoteViewModel : ViewModel() {
private var totalZapped: BigDecimal = BigDecimal.ZERO
private var wasZappedByLoggedInAccount: Boolean = false
- private val _tallies = MutableStateFlow>(emptyList())
- val tallies = _tallies.asStateFlow()
+ var canZap = mutableStateOf(false)
+ var tallies: List = emptyList()
fun load(
acc: Account,
note: Note?,
) {
- account = acc
- pollNote = note
- pollEvent = pollNote?.event as PollNoteEvent
- pollOptions = pollEvent?.pollOptions()
- valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
- valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
- valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
- valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
- consensusThreshold =
- pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
- closedAt = pollEvent?.getTagLong(CLOSED_AT)
+ if (acc != account || pollNote != note) {
+ account = acc
+ pollNote = note
+ pollEvent = pollNote?.event as PollNoteEvent
+ pollOptions = pollEvent?.pollOptions()
+ valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
+ valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
+ valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
+ valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
+ consensusThreshold =
+ pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
+ closedAt = pollEvent?.getTagLong(CLOSED_AT)
+
+ totalZapped = BigDecimal.ZERO
+ wasZappedByLoggedInAccount = false
+
+ canZap.value = checkIfCanZap()
+
+ tallies = pollOptions?.keys?.map { option ->
+ PollOption(
+ option,
+ pollOptions?.get(option) ?: "",
+ )
+ } ?: emptyList()
+ }
}
fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
- account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true }
+ account?.calculateIfNoteWasZappedByAccount(pollNote) {
+ wasZappedByLoggedInAccount = true
+ canZap.value = checkIfCanZap()
+ }
- val newOptions =
- pollOptions?.keys?.map { option ->
- val zappedInOption = zappedPollOptionAmount(option)
+ tallies.forEach {
+ val zappedValue = zappedPollOptionAmount(it.option)
+ val tallyValue =
+ if (totalZapped > BigDecimal.ZERO) {
+ zappedValue.divide(totalZapped, 2, RoundingMode.HALF_UP)
+ } else {
+ BigDecimal.ZERO
+ }
- val myTally =
- if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
- zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
- } else {
- BigDecimal.ZERO
- }
-
- val cachedZappedByLoggedIn =
- account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false
-
- val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
-
- PollOption(
- option,
- pollOptions?.get(option) ?: "",
- zappedInOption,
- myTally,
- consensus,
- cachedZappedByLoggedIn,
- )
- }
-
- _tallies.emit(
- newOptions ?: emptyList(),
- )
+ it.zappedValue.value = zappedValue
+ it.tally.value = tallyValue
+ it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
+ it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
+ }
}
}
- fun canZap(): Boolean {
+ fun checkIfCanZap(): Boolean {
val account = account ?: return false
val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
index 794a20752..49c4f39bd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
@@ -61,6 +61,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -99,8 +100,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.NewPostView
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
+import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
@@ -145,6 +148,7 @@ import kotlin.math.roundToInt
fun ReactionsRow(
baseNote: Note,
showReactionDetail: Boolean,
+ editState: State>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -152,7 +156,7 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
- InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
+ InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, editState, accountViewModel, nav)
Spacer(modifier = HalfDoubleVertSpacer)
@@ -169,6 +173,7 @@ private fun InnerReactionRow(
baseNote: Note,
showReactionDetail: Boolean,
wantsToSeeReactions: MutableState,
+ editState: State>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -190,6 +195,7 @@ private fun InnerReactionRow(
three = {
BoostWithDialog(
baseNote,
+ editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
@@ -301,7 +307,7 @@ fun RenderZapRaiser(
LinearProgressIndicator(
modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) },
color = color,
- progress = zapraiserStatus.progress,
+ progress = { zapraiserStatus.progress },
)
if (details) {
@@ -495,6 +501,7 @@ private fun WatchZapAndRenderGallery(
@Composable
private fun BoostWithDialog(
baseNote: Note,
+ editState: State>,
grayTint: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -507,6 +514,7 @@ private fun BoostWithDialog(
onClose = { wantsToQuote = null },
baseReplyTo = null,
quote = wantsToQuote,
+ version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -528,6 +536,7 @@ private fun BoostWithDialog(
onClose = { wantsToFork = null },
baseReplyTo = replyTo,
fork = wantsToFork,
+ version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
accountViewModel = accountViewModel,
nav = nav,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt
index 55a1a16e7..a39aa5c26 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt
@@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -101,7 +101,7 @@ fun RelayCompose(
}
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt
index 71c661083..2d946e5a1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt
@@ -20,11 +20,19 @@
*/
package com.vitorpamplona.amethyst.ui.note
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
@@ -37,14 +45,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
-import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifier
-import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalLayoutApi::class)
@@ -58,16 +69,18 @@ fun RelayBadges(
val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays)
- Spacer(DoubleVertSpacer)
+ Spacer(StdVertSpacer)
// FlowRow Seems to be a lot faster than LazyVerticalGrid
- FlowRow {
- if (expanded) {
- relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
- } else {
- relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
- relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
- relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
+ Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
+ FlowRow(modifier = Modifier.fillMaxWidth()) {
+ if (expanded) {
+ relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
+ } else {
+ relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
+ relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
+ relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
+ }
}
}
@@ -76,6 +89,62 @@ fun RelayBadges(
}
}
+@OptIn(ExperimentalLayoutApi::class)
+@Preview
+@Composable
+fun RelayIconLayoutPreview() {
+ Column(modifier = Modifier.width(55.dp)) {
+ Spacer(StdVertSpacer)
+
+ // FlowRow Seems to be a lot faster than LazyVerticalGrid
+ Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
+ FlowRow {
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black),
+ )
+ }
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
+ }
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
+ }
+
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
+ }
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
+ }
+ Box(
+ modifier = Modifier.size(17.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
+ }
+ }
+ }
+
+ ShowMoreRelaysButton { }
+ }
+}
+
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
@@ -84,13 +153,11 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) {
verticalAlignment = Alignment.Top,
) {
IconButton(
- modifier = ShowMoreRelaysButtonIconButtonModifier,
onClick = onClick,
) {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = stringResource(id = R.string.expand_relay_list),
- modifier = ShowMoreRelaysButtonIconModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt
index cac24a089..ee5ecfcde 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
@@ -47,7 +46,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -59,7 +57,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size15dp
+import com.vitorpamplona.amethyst.ui.theme.Size17dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.relayIconModifier
@@ -164,13 +162,12 @@ fun RenderRelay(
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
- val ripple = rememberRipple(bounded = false, radius = Size15dp)
+ val ripple = rememberRipple(bounded = false, radius = Size17dp)
val clickableModifier =
remember(relay) {
Modifier
- .padding(1.dp)
- .size(Size15dp)
+ .size(Size17dp)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt
index b6861152c..68d1109c8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
index e81d9bee4..bd8599b8a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
@@ -51,7 +51,7 @@ import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -408,7 +408,7 @@ fun UpdateZapAmountDialog(
)
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
)
@@ -445,7 +445,7 @@ fun UpdateZapAmountDialog(
) {
Icon(
painter = painterResource(R.drawable.alby),
- null,
+ contentDescription = stringResource(id = R.string.accessibility_navigate_to_alby),
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
@@ -454,7 +454,7 @@ fun UpdateZapAmountDialog(
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
- null,
+ contentDescription = stringResource(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt
index d808056d3..6026fb233 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt
@@ -24,7 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -68,7 +68,7 @@ fun UserCompose(
}
if (showDiviser) {
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt
index b49ef2786..523afae1d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note
-import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
@@ -34,33 +33,20 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
-import androidx.compose.material3.Divider
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
-import androidx.core.content.ContextCompat
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
@@ -69,11 +55,8 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.quartz.encoders.HexKey
import kotlinx.collections.immutable.ImmutableSet
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
@Composable
fun NoteAuthorPicture(
@@ -439,270 +422,3 @@ fun WatchUserFollows(
onFollowChanges(showFollowingMark)
}
-
-@Immutable
-data class DropDownParams(
- val isFollowingAuthor: Boolean,
- val isPrivateBookmarkNote: Boolean,
- val isPublicBookmarkNote: Boolean,
- val isLoggedUser: Boolean,
- val isSensitive: Boolean,
- val showSensitiveContent: Boolean?,
-)
-
-@Composable
-fun NoteDropDownMenu(
- note: Note,
- popupExpanded: MutableState,
- accountViewModel: AccountViewModel,
-) {
- var reportDialogShowing by remember { mutableStateOf(false) }
-
- var state by remember {
- mutableStateOf(
- DropDownParams(
- isFollowingAuthor = false,
- isPrivateBookmarkNote = false,
- isPublicBookmarkNote = false,
- isLoggedUser = false,
- isSensitive = false,
- showSensitiveContent = null,
- ),
- )
- }
-
- val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } }
-
- DropdownMenu(
- expanded = popupExpanded.value,
- onDismissRequest = onDismiss,
- ) {
- val clipboardManager = LocalClipboardManager.current
- val appContext = LocalContext.current.applicationContext
- val actContext = LocalContext.current
-
- WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
- if (state != newState) {
- state = newState
- }
- }
-
- val scope = rememberCoroutineScope()
-
- if (!state.isFollowingAuthor) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.follow)) },
- onClick = {
- val author = note.author ?: return@DropdownMenuItem
- accountViewModel.follow(author)
- onDismiss()
- },
- )
- Divider()
- }
- DropdownMenuItem(
- text = { Text(stringResource(R.string.copy_text)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) }
- onDismiss()
- }
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.copy_user_pubkey)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
- onDismiss()
- }
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.copy_note_id)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent()))
- onDismiss()
- }
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.quick_action_share)) },
- onClick = {
- val sendIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- type = "text/plain"
- putExtra(
- Intent.EXTRA_TEXT,
- externalLinkForNote(note),
- )
- putExtra(
- Intent.EXTRA_TITLE,
- actContext.getString(R.string.quick_action_share_browser_link),
- )
- }
-
- val shareIntent =
- Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share))
- ContextCompat.startActivity(actContext, shareIntent, null)
- onDismiss()
- },
- )
- Divider()
- DropdownMenuItem(
- text = { Text(stringResource(R.string.broadcast)) },
- onClick = {
- accountViewModel.broadcast(note)
- onDismiss()
- },
- )
- Divider()
- if (accountViewModel.account.hasPendingAttestations(note)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.timestamp_pending)) },
- onClick = {
- onDismiss()
- },
- )
- } else {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.timestamp_it)) },
- onClick = {
- accountViewModel.timestamp(note)
- onDismiss()
- },
- )
- }
- Divider()
- if (state.isPrivateBookmarkNote) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.remove_from_private_bookmarks)) },
- onClick = {
- accountViewModel.removePrivateBookmark(note)
- onDismiss()
- },
- )
- } else {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.add_to_private_bookmarks)) },
- onClick = {
- accountViewModel.addPrivateBookmark(note)
- onDismiss()
- },
- )
- }
- if (state.isPublicBookmarkNote) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.remove_from_public_bookmarks)) },
- onClick = {
- accountViewModel.removePublicBookmark(note)
- onDismiss()
- },
- )
- } else {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.add_to_public_bookmarks)) },
- onClick = {
- accountViewModel.addPublicBookmark(note)
- onDismiss()
- },
- )
- }
- Divider()
- if (state.showSensitiveContent == null || state.showSensitiveContent == true) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- accountViewModel.hideSensitiveContent()
- onDismiss()
- }
- },
- )
- }
- if (state.showSensitiveContent == null || state.showSensitiveContent == false) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- accountViewModel.disableContentWarnings()
- onDismiss()
- }
- },
- )
- }
- if (state.showSensitiveContent != null) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.content_warning_see_warnings)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- accountViewModel.seeContentWarnings()
- onDismiss()
- }
- },
- )
- }
- Divider()
- if (state.isLoggedUser) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.request_deletion)) },
- onClick = {
- scope.launch(Dispatchers.IO) {
- accountViewModel.delete(note)
- onDismiss()
- }
- },
- )
- } else {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.block_report)) },
- onClick = { reportDialogShowing = true },
- )
- }
- }
-
- if (reportDialogShowing) {
- ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
- reportDialogShowing = false
- onDismiss()
- }
- }
-}
-
-@Composable
-fun WatchBookmarksFollowsAndAccount(
- note: Note,
- accountViewModel: AccountViewModel,
- onNew: (DropDownParams) -> Unit,
-) {
- val followState by accountViewModel.userProfile().live().follows.observeAsState()
- val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
- val showSensitiveContent by
- accountViewModel.showSensitiveContentChanges.observeAsState(
- accountViewModel.account.showSensitiveContent,
- )
-
- LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
- launch(Dispatchers.IO) {
- accountViewModel.isInPrivateBookmarks(note) {
- val newState =
- DropDownParams(
- isFollowingAuthor = accountViewModel.isFollowing(note.author),
- isPrivateBookmarkNote = it,
- isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
- isLoggedUser = accountViewModel.isLoggedUser(note.author),
- isSensitive = note.event?.isSensitive() ?: false,
- showSensitiveContent = showSensitiveContent,
- )
-
- launch(Dispatchers.Main) {
- onNew(
- newState,
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt
index 3c2e42730..7fd4de335 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt
@@ -29,7 +29,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@@ -72,67 +71,27 @@ fun UsernameDisplay(
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified,
) {
- val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() } }
-
val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") {
- if (it != null) {
- UserNameDisplay(
- it.bestUsername(),
- it.bestDisplayName(),
- npubDisplay,
- it.tags,
- weight,
- showPlayButton,
- fontWeight,
- textColor,
- )
+ val name = it?.bestDisplayName() ?: it?.bestUsername()
+ if (name != null) {
+ UserDisplay(name, it?.tags, weight, showPlayButton, fontWeight, textColor)
} else {
- NPubDisplay(npubDisplay, weight, fontWeight, textColor)
+ NPubDisplay(baseUser, weight, fontWeight, textColor)
}
}
}
@Composable
-private fun UserNameDisplay(
- bestUserName: String?,
- bestDisplayName: String?,
- npubDisplay: String,
- tags: ImmutableListOfLists?,
- modifier: Modifier,
- showPlayButton: Boolean = true,
- fontWeight: FontWeight = FontWeight.Bold,
- textColor: Color = Color.Unspecified,
-) {
- if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
- UserAndUsernameDisplay(
- bestDisplayName.trim(),
- tags,
- bestUserName.trim(),
- modifier,
- showPlayButton,
- fontWeight,
- textColor,
- )
- } else if (bestDisplayName != null) {
- UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
- } else if (bestUserName != null) {
- UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
- } else {
- NPubDisplay(npubDisplay, modifier, fontWeight, textColor)
- }
-}
-
-@Composable
-fun NPubDisplay(
- npubDisplay: String,
+private fun NPubDisplay(
+ user: User,
modifier: Modifier,
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified,
) {
Text(
- text = npubDisplay,
+ text = remember { user.pubkeyDisplayHex() },
fontWeight = fontWeight,
modifier = modifier,
maxLines = 1,
@@ -160,41 +119,7 @@ private fun UserDisplay(
modifier = modifier,
color = textColor,
)
- if (showPlayButton) {
- Spacer(StdHorzSpacer)
- DrawPlayName(bestDisplayName)
- }
- }
-}
-@Composable
-private fun UserAndUsernameDisplay(
- bestDisplayName: String,
- tags: ImmutableListOfLists?,
- bestUserName: String,
- modifier: Modifier,
- showPlayButton: Boolean = true,
- fontWeight: FontWeight = FontWeight.Bold,
- textColor: Color = Color.Unspecified,
-) {
- Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
- CreateTextWithEmoji(
- text = bestDisplayName,
- tags = tags,
- fontWeight = fontWeight,
- maxLines = 1,
- modifier = modifier,
- color = textColor,
- )
- /*
- CreateTextWithEmoji(
- text = remember { "@$bestUserName" },
- tags = tags,
- color = MaterialTheme.colorScheme.placeholderText,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
-
- )*/
if (showPlayButton) {
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
@@ -207,12 +132,7 @@ fun DrawPlayName(name: String) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
- DrawPlayNameIcon { speak(name, context, lifecycleOwner) }
-}
-
-@Composable
-fun DrawPlayNameIcon(onClick: () -> Unit) {
- IconButton(onClick = onClick, modifier = StdButtonSizeModifier) {
+ IconButton(onClick = { speak(name, context, lifecycleOwner) }, modifier = StdButtonSizeModifier) {
PlayIcon(
modifier = StdButtonSizeModifier,
tint = MaterialTheme.colorScheme.placeholderText,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt
index 91d114954..d5d293cd6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt
@@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -91,7 +91,7 @@ fun ZapNoteCompose(
) {
baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) }
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt
index 593f6bc3a..f00c680f8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt
@@ -69,9 +69,9 @@ import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.LoadNote
-import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt
index 9f93e498e..2400fc84e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt
@@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -132,7 +132,7 @@ fun ZapUserSetCompose(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt
similarity index 98%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt
index e656751b1..9ec25686a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt
new file mode 100644
index 000000000..acb98798c
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+
+@Composable
+fun BoostedMark() {
+ Text(
+ stringResource(id = R.string.boosted),
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 1,
+ modifier = HalfStartPadding,
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt
new file mode 100644
index 000000000..055f77bd0
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size55dp
+import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader
+import com.vitorpamplona.amethyst.ui.theme.imageHeaderBannerSize
+
+@Composable
+fun DefaultImageHeader(
+ note: Note,
+ accountViewModel: AccountViewModel,
+) {
+ Box {
+ note.author?.info?.banner?.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.preview_card_image_for,
+ it,
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ?: Image(
+ painter = painterResource(R.drawable.profile_banner),
+ contentDescription = stringResource(R.string.profile_banner),
+ contentScale = ContentScale.FillWidth,
+ modifier = imageHeaderBannerSize,
+ )
+
+ Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) {
+ NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp)
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt
similarity index 98%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt
index 0685e1090..1459b0c81 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt
new file mode 100644
index 000000000..0981fc846
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.note.types.EditState
+import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+
+@Composable
+fun DisplayEditStatus(editState: EditState) {
+ ClickableText(
+ text =
+ buildAnnotatedString {
+ if (editState.showingVersion.value == editState.originalVersionId()) {
+ append(stringResource(id = R.string.original))
+ } else if (editState.showingVersion.value == editState.lastVersionId()) {
+ append(stringResource(id = R.string.edited))
+ } else {
+ append(stringResource(id = R.string.edited_number, editState.versionId()))
+ }
+ },
+ onClick = {
+ editState.nextModification()
+ },
+ style =
+ LocalTextStyle.current.copy(
+ color = MaterialTheme.colorScheme.placeholderText,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ modifier = HalfStartPadding,
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt
similarity index 86%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt
index cd9f71574..54d3d424c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -37,8 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
@Composable
fun DisplayFollowingHashtagsInPost(
@@ -50,13 +48,11 @@ fun DisplayFollowingHashtagsInPost(
var firstTag by remember(baseNote) { mutableStateOf(null) }
LaunchedEffect(key1 = userFollowState) {
- launch(Dispatchers.Default) {
- val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
- val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags)
+ val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
+ val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags)
- if (firstTag != newFirstTag) {
- launch(Dispatchers.Main) { firstTag = newFirstTag }
- }
+ if (firstTag != newFirstTag) {
+ firstTag = newFirstTag
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt
new file mode 100644
index 000000000..4f15be3cc
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import com.vitorpamplona.amethyst.ui.note.LoadCityName
+import com.vitorpamplona.amethyst.ui.theme.Font14SP
+
+@Composable
+fun DisplayLocation(
+ geohashStr: String,
+ nav: (String) -> Unit,
+) {
+ LoadCityName(geohashStr) { cityName ->
+ ClickableText(
+ text = AnnotatedString(cityName),
+ onClick = { nav("Geohash/$geohashStr") },
+ style =
+ LocalTextStyle.current.copy(
+ color =
+ MaterialTheme.colorScheme.primary.copy(
+ alpha = 0.52f,
+ ),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt
new file mode 100644
index 000000000..4c3488c64
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.LoadOts
+import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Font14SP
+import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
+import java.text.SimpleDateFormat
+import java.util.Date
+
+@Composable
+fun DisplayOts(
+ note: Note,
+ accountViewModel: AccountViewModel,
+) {
+ LoadOts(
+ note,
+ accountViewModel,
+ whenConfirmed = { unixtimestamp ->
+ val context = LocalContext.current
+ val timeStr by remember(unixtimestamp) {
+ mutableStateOf(
+ timeAgoNoDot(
+ unixtimestamp,
+ context = context,
+ ),
+ )
+ }
+
+ ClickableText(
+ text =
+ buildAnnotatedString {
+ append(
+ stringResource(
+ id = R.string.existed_since,
+ timeStr,
+ ),
+ )
+ },
+ onClick = {
+ val fullDateTime =
+ SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000))
+
+ accountViewModel.toast(
+ context.getString(R.string.ots_info_title),
+ context.getString(R.string.ots_info_description, fullDateTime),
+ )
+ },
+ style =
+ LocalTextStyle.current.copy(
+ color = MaterialTheme.colorScheme.lessImportantLink,
+ fontSize = Font14SP,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ )
+ },
+ whenPending = {
+ Text(
+ stringResource(id = R.string.timestamp_pending_short),
+ color = MaterialTheme.colorScheme.lessImportantLink,
+ fontSize = Font14SP,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ },
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt
similarity index 97%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt
index 184ebe35c..842dc8c5e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt
similarity index 99%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt
index 2c7520103..5fc6a69ed 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt
similarity index 97%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt
index e185ab24f..551f60689 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt
similarity index 98%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt
index ecdf5ad57..40d63f7e2 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.ui.elements
+package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt
new file mode 100644
index 000000000..27d37e018
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt
@@ -0,0 +1,393 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import android.content.Intent
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.core.content.ContextCompat
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.actions.EditPostView
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
+import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
+import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
+import com.vitorpamplona.amethyst.ui.note.types.EditState
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
+import com.vitorpamplona.quartz.events.TextNoteEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun MoreOptionsButton(
+ baseNote: Note,
+ editState: State>? = null,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val popupExpanded = remember { mutableStateOf(false) }
+
+ IconButton(
+ modifier = Size24Modifier,
+ onClick = { popupExpanded.value = true },
+ ) {
+ VerticalDotsIcon(R.string.note_options)
+
+ NoteDropDownMenu(
+ baseNote,
+ popupExpanded,
+ editState,
+ accountViewModel,
+ nav,
+ )
+ }
+}
+
+@Immutable
+data class DropDownParams(
+ val isFollowingAuthor: Boolean,
+ val isPrivateBookmarkNote: Boolean,
+ val isPublicBookmarkNote: Boolean,
+ val isLoggedUser: Boolean,
+ val isSensitive: Boolean,
+ val showSensitiveContent: Boolean?,
+)
+
+@Composable
+fun NoteDropDownMenu(
+ note: Note,
+ popupExpanded: MutableState,
+ editState: State>? = null,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ var reportDialogShowing by remember { mutableStateOf(false) }
+
+ var state by remember {
+ mutableStateOf(
+ DropDownParams(
+ isFollowingAuthor = false,
+ isPrivateBookmarkNote = false,
+ isPublicBookmarkNote = false,
+ isLoggedUser = false,
+ isSensitive = false,
+ showSensitiveContent = null,
+ ),
+ )
+ }
+
+ val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } }
+
+ val wantsToEditPost =
+ remember {
+ mutableStateOf(false)
+ }
+
+ if (wantsToEditPost.value) {
+ // avoids changing while drafting a note and a new event shows up.
+ val versionLookingAt =
+ remember {
+ (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value
+ }
+
+ EditPostView(
+ onClose = {
+ popupExpanded.value = false
+ wantsToEditPost.value = false
+ },
+ edit = note,
+ versionLookingAt = versionLookingAt,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ DropdownMenu(
+ expanded = popupExpanded.value,
+ onDismissRequest = onDismiss,
+ ) {
+ val clipboardManager = LocalClipboardManager.current
+ val appContext = LocalContext.current.applicationContext
+ val actContext = LocalContext.current
+
+ WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
+ if (state != newState) {
+ state = newState
+ }
+ }
+
+ val scope = rememberCoroutineScope()
+
+ if (!state.isFollowingAuthor) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.follow)) },
+ onClick = {
+ val author = note.author ?: return@DropdownMenuItem
+ accountViewModel.follow(author)
+ onDismiss()
+ },
+ )
+ HorizontalDivider(thickness = DividerThickness)
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.copy_text)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) }
+ onDismiss()
+ }
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.copy_user_pubkey)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
+ onDismiss()
+ }
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.copy_note_id)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent()))
+ onDismiss()
+ }
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.quick_action_share)) },
+ onClick = {
+ val sendIntent =
+ Intent().apply {
+ action = Intent.ACTION_SEND
+ type = "text/plain"
+ putExtra(
+ Intent.EXTRA_TEXT,
+ externalLinkForNote(note),
+ )
+ putExtra(
+ Intent.EXTRA_TITLE,
+ actContext.getString(R.string.quick_action_share_browser_link),
+ )
+ }
+
+ val shareIntent =
+ Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share))
+ ContextCompat.startActivity(actContext, shareIntent, null)
+ onDismiss()
+ },
+ )
+ HorizontalDivider(thickness = DividerThickness)
+ if (note.event is TextNoteEvent) {
+ if (state.isLoggedUser) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.edit_post)) },
+ onClick = {
+ wantsToEditPost.value = true
+ },
+ )
+ } else {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.propose_an_edit)) },
+ onClick = {
+ wantsToEditPost.value = true
+ },
+ )
+ }
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.broadcast)) },
+ onClick = {
+ accountViewModel.broadcast(note)
+ onDismiss()
+ },
+ )
+ HorizontalDivider(thickness = DividerThickness)
+ if (accountViewModel.account.hasPendingAttestations(note)) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.timestamp_pending)) },
+ onClick = {
+ onDismiss()
+ },
+ )
+ } else {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.timestamp_it)) },
+ onClick = {
+ accountViewModel.timestamp(note)
+ onDismiss()
+ },
+ )
+ }
+ HorizontalDivider(thickness = DividerThickness)
+ if (state.isPrivateBookmarkNote) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.remove_from_private_bookmarks)) },
+ onClick = {
+ accountViewModel.removePrivateBookmark(note)
+ onDismiss()
+ },
+ )
+ } else {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.add_to_private_bookmarks)) },
+ onClick = {
+ accountViewModel.addPrivateBookmark(note)
+ onDismiss()
+ },
+ )
+ }
+ if (state.isPublicBookmarkNote) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.remove_from_public_bookmarks)) },
+ onClick = {
+ accountViewModel.removePublicBookmark(note)
+ onDismiss()
+ },
+ )
+ } else {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.add_to_public_bookmarks)) },
+ onClick = {
+ accountViewModel.addPublicBookmark(note)
+ onDismiss()
+ },
+ )
+ }
+ HorizontalDivider(thickness = DividerThickness)
+ if (state.showSensitiveContent == null || state.showSensitiveContent == true) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.hideSensitiveContent()
+ onDismiss()
+ }
+ },
+ )
+ }
+ if (state.showSensitiveContent == null || state.showSensitiveContent == false) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.disableContentWarnings()
+ onDismiss()
+ }
+ },
+ )
+ }
+ if (state.showSensitiveContent != null) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.content_warning_see_warnings)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.seeContentWarnings()
+ onDismiss()
+ }
+ },
+ )
+ }
+ HorizontalDivider(thickness = DividerThickness)
+ if (state.isLoggedUser) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.request_deletion)) },
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.delete(note)
+ onDismiss()
+ }
+ },
+ )
+ } else {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.block_report)) },
+ onClick = { reportDialogShowing = true },
+ )
+ }
+ }
+
+ if (reportDialogShowing) {
+ ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
+ reportDialogShowing = false
+ onDismiss()
+ }
+ }
+}
+
+@Composable
+fun WatchBookmarksFollowsAndAccount(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ onNew: (DropDownParams) -> Unit,
+) {
+ val followState by accountViewModel.userProfile().live().follows.observeAsState()
+ val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
+ val showSensitiveContent by
+ accountViewModel.showSensitiveContentChanges.observeAsState(
+ accountViewModel.account.showSensitiveContent,
+ )
+
+ LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
+ launch(Dispatchers.IO) {
+ accountViewModel.isInPrivateBookmarks(note) {
+ val newState =
+ DropDownParams(
+ isFollowingAuthor = accountViewModel.isFollowing(note.author),
+ isPrivateBookmarkNote = it,
+ isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
+ isLoggedUser = accountViewModel.isLoggedUser(note.author),
+ isSensitive = note.event?.isSensitive() ?: false,
+ showSensitiveContent = showSensitiveContent,
+ )
+
+ launch(Dispatchers.Main) {
+ onNew(
+ newState,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt
new file mode 100644
index 000000000..62ccc2ce3
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt
@@ -0,0 +1,155 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextOverflow
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
+import com.vitorpamplona.amethyst.ui.components.LoadNote
+import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Font14SP
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.nip05
+import com.vitorpamplona.quartz.events.BaseTextNoteEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+
+@Composable
+fun ShowForkInformation(
+ noteEvent: BaseTextNoteEvent,
+ modifier: Modifier,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() }
+ val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() }
+ if (forkedAddress != null) {
+ LoadAddressableNote(
+ aTag = forkedAddress,
+ accountViewModel = accountViewModel,
+ ) { addressableNote ->
+ if (addressableNote != null) {
+ ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav)
+ }
+ }
+ } else if (forkedEvent != null) {
+ LoadNote(forkedEvent, accountViewModel = accountViewModel) { event ->
+ if (event != null) {
+ ForkInformationRowLightColor(event, modifier, accountViewModel, nav)
+ }
+ }
+ }
+}
+
+@Composable
+fun ForkInformationRowLightColor(
+ originalVersion: Note,
+ modifier: Modifier = Modifier,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteState by originalVersion.live().metadata.observeAsState()
+ val note = noteState?.note ?: return
+ val author = note.author ?: return
+ val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
+
+ if (route != null) {
+ Row(modifier) {
+ ClickableText(
+ text =
+ buildAnnotatedString {
+ append(stringResource(id = R.string.forked_from))
+ append(" ")
+ },
+ onClick = { nav(route) },
+ style =
+ LocalTextStyle.current.copy(
+ color = MaterialTheme.colorScheme.nip05,
+ fontSize = Font14SP,
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Visible,
+ )
+
+ val userState by author.live().metadata.observeAsState()
+ val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
+ val userTags =
+ remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
+
+ if (userDisplayName != null) {
+ CreateClickableTextWithEmoji(
+ clickablePart = userDisplayName,
+ maxLines = 1,
+ route = route,
+ overrideColor = MaterialTheme.colorScheme.nip05,
+ fontSize = Font14SP,
+ nav = nav,
+ tags = userTags,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ForkInformationRow(
+ originalVersion: Note,
+ modifier: Modifier = Modifier,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteState by originalVersion.live().metadata.observeAsState()
+ val note = noteState?.note ?: return
+ val author = note.author ?: return
+ val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
+
+ if (route != null) {
+ Row(modifier) {
+ Text(stringResource(id = R.string.forked_from))
+ Spacer(modifier = StdHorzSpacer)
+
+ val userMetadata by author.live().userMetadataInfo.observeAsState()
+
+ CreateClickableTextWithEmoji(
+ clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: author.pubkeyDisplayHex(),
+ maxLines = 1,
+ route = route,
+ nav = nav,
+ tags = userMetadata?.tags,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt
new file mode 100644
index 000000000..9c48a2393
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.elements
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.timeAgo
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+
+@Composable
+fun TimeAgo(note: Note) {
+ val time = remember(note) { note.createdAt() } ?: return
+ TimeAgo(time)
+}
+
+@Composable
+fun TimeAgo(time: Long) {
+ val context = LocalContext.current
+ val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
+
+ Text(
+ text = timeStr,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 1,
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt
new file mode 100644
index 000000000..fcffd450b
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt
@@ -0,0 +1,246 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.RichTextParser
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
+import com.vitorpamplona.amethyst.ui.note.LinkIcon
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
+import com.vitorpamplona.amethyst.ui.theme.Size35dp
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.events.AppDefinitionEvent
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.UserMetadata
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun RenderAppDefinition(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? AppDefinitionEvent ?: return
+
+ var metadata by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = noteEvent) {
+ launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() }
+ }
+
+ metadata?.let {
+ Box {
+ val clipboardManager = LocalClipboardManager.current
+ val uri = LocalUriHandler.current
+
+ if (!it.banner.isNullOrBlank()) {
+ var zoomImageDialogOpen by remember { mutableStateOf(false) }
+
+ AsyncImage(
+ model = it.banner,
+ contentDescription = stringResource(id = R.string.profile_image),
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(125.dp)
+ .combinedClickable(
+ onClick = {},
+ onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) },
+ ),
+ )
+
+ if (zoomImageDialogOpen) {
+ ZoomableImageDialog(
+ imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
+ onDismiss = { zoomImageDialogOpen = false },
+ accountViewModel = accountViewModel,
+ )
+ }
+ } else {
+ Image(
+ painter = painterResource(R.drawable.profile_banner),
+ contentDescription = stringResource(id = R.string.profile_banner),
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(125.dp),
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 10.dp)
+ .padding(top = 75.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ var zoomImageDialogOpen by remember { mutableStateOf(false) }
+
+ Box(Modifier.size(100.dp)) {
+ it.picture?.let {
+ AsyncImage(
+ model = it,
+ contentDescription = null,
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .border(
+ 3.dp,
+ MaterialTheme.colorScheme.background,
+ CircleShape,
+ )
+ .clip(shape = CircleShape)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .combinedClickable(
+ onClick = { zoomImageDialogOpen = true },
+ onLongClick = { clipboardManager.setText(AnnotatedString(it)) },
+ ),
+ )
+ }
+ }
+
+ if (zoomImageDialogOpen) {
+ ZoomableImageDialog(
+ imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
+ onDismiss = { zoomImageDialogOpen = false },
+ accountViewModel = accountViewModel,
+ )
+ }
+
+ Spacer(Modifier.weight(1f))
+
+ Row(
+ modifier =
+ Modifier
+ .height(Size35dp)
+ .padding(bottom = 3.dp),
+ ) {}
+ }
+
+ val name = remember(it) { it.anyName() }
+ name?.let {
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier.padding(top = 7.dp),
+ ) {
+ CreateTextWithEmoji(
+ text = it,
+ tags =
+ remember {
+ (note.event?.tags() ?: emptyArray()).toImmutableListOfLists()
+ },
+ fontWeight = FontWeight.Bold,
+ fontSize = 25.sp,
+ )
+ }
+ }
+
+ val website = remember(it) { it.website }
+ if (!website.isNullOrEmpty()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
+
+ ClickableText(
+ text = AnnotatedString(website.removePrefix("https://")),
+ onClick = { website.let { runCatching { uri.openUri(it) } } },
+ style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
+ modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
+ )
+ }
+ }
+
+ it.about?.let {
+ Row(
+ modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
+ ) {
+ val tags =
+ remember(note) {
+ note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList
+ }
+ val bgColor = MaterialTheme.colorScheme.background
+ val backgroundColor = remember { mutableStateOf(bgColor) }
+ TranslatableRichTextViewer(
+ content = it,
+ canPreview = false,
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt
new file mode 100644
index 000000000..78f442277
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt
@@ -0,0 +1,236 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.components.VideoView
+import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
+import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.events.AudioHeaderEvent
+import com.vitorpamplona.quartz.events.AudioTrackEvent
+import com.vitorpamplona.quartz.events.Participant
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.toImmutableList
+import java.util.Locale
+
+@Composable
+fun RenderAudioTrack(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? AudioTrackEvent ?: return
+
+ AudioTrackHeader(noteEvent, note, accountViewModel, nav)
+}
+
+@Composable
+fun AudioTrackHeader(
+ noteEvent: AudioTrackEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ 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>>(emptyList()) }
+
+ LaunchedEffect(key1 = participants) {
+ accountViewModel.loadParticipants(participants) { participantUsers = 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 {
+ nav("User/${it.second.pubkeyHex}")
+ },
+ ) {
+ ClickableUserPicture(it.second, 25.dp, accountViewModel)
+ 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.colorScheme.placeholderText,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+
+ media?.let { media ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ cover?.let { cover ->
+ LoadThumbAndThenVideoView(
+ videoUri = media,
+ title = noteEvent.subject(),
+ thumbUri = cover,
+ authorName = note.author?.toBestDisplayName(),
+ roundedCorner = true,
+ nostrUriCallback = "nostr:${note.toNEvent()}",
+ accountViewModel = accountViewModel,
+ )
+ }
+ ?: VideoView(
+ videoUri = media,
+ title = noteEvent.subject(),
+ authorName = note.author?.toBestDisplayName(),
+ roundedCorner = true,
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderAudioHeader(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? AudioHeaderEvent ?: return
+
+ AudioHeader(noteEvent, note, accountViewModel, nav)
+}
+
+@Composable
+fun AudioHeader(
+ noteEvent: AudioHeaderEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val media = remember { noteEvent.stream() ?: noteEvent.download() }
+ val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } }
+ val content = remember { noteEvent.content().ifBlank { null } }
+
+ val defaultBackground = MaterialTheme.colorScheme.background
+ val background = remember { mutableStateOf(defaultBackground) }
+ val tags = remember(noteEvent) { noteEvent.tags().toImmutableListOfLists() }
+
+ Row(modifier = Modifier.padding(top = 5.dp)) {
+ Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ media?.let { media ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ VideoView(
+ videoUri = media,
+ waveform = waveform,
+ title = noteEvent.subject(),
+ authorName = note.author?.toBestDisplayName(),
+ roundedCorner = true,
+ accountViewModel = accountViewModel,
+ nostrUriCallback = note.toNostrUri(),
+ )
+ }
+ }
+
+ content?.let {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 5.dp),
+ ) {
+ TranslatableRichTextViewer(
+ content = it,
+ canPreview = true,
+ tags = tags,
+ backgroundColor = background,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+
+ if (noteEvent.hasHashtags()) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() }
+ DisplayUncitedHashtags(hashtags, content ?: "", nav)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt
new file mode 100644
index 000000000..f6deaa3fe
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt
@@ -0,0 +1,233 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CutCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.graphics.get
+import coil.compose.AsyncImage
+import coil.compose.AsyncImagePainter
+import coil.request.SuccessResult
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size35dp
+import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
+import com.vitorpamplona.quartz.events.BadgeAwardEvent
+import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun BadgeDisplay(baseNote: Note) {
+ val background = MaterialTheme.colorScheme.background
+ val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
+
+ val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() }
+ val name = remember { badgeData.name() }
+ val description = remember { badgeData.description() }
+
+ var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) }
+ var imageResult by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = imageResult) {
+ launch(Dispatchers.IO) {
+ imageResult?.let {
+ val backgroundColor =
+ it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
+ val colorFromImage = Color(backgroundColor)
+ val textBackground =
+ if (colorFromImage.luminance() > 0.5) {
+ lightColorScheme().onBackground
+ } else {
+ darkColorScheme().onBackground
+ }
+
+ launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) }
+ }
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(10.dp)
+ .clip(shape = CutCornerShape(20, 20, 20, 20))
+ .border(
+ 5.dp,
+ MaterialTheme.colorScheme.mediumImportanceLink,
+ CutCornerShape(20),
+ )
+ .background(backgroundFromImage.first),
+ ) {
+ RenderBadge(
+ image,
+ name,
+ backgroundFromImage.second,
+ description,
+ ) {
+ if (imageResult == null) {
+ imageResult = it.result
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderBadge(
+ image: String?,
+ name: String?,
+ backgroundFromImage: Color,
+ description: String?,
+ onSuccess: (AsyncImagePainter.State.Success) -> Unit,
+) {
+ Column {
+ image.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.badge_award_image_for,
+ name ?: "",
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ onSuccess = onSuccess,
+ )
+ }
+
+ name?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp),
+ color = backgroundFromImage,
+ )
+ }
+
+ description?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
+ color = Color.Gray,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun RenderBadgeAward(
+ note: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ if (note.replyTo.isNullOrEmpty()) return
+
+ val noteEvent = note.event as? BadgeAwardEvent ?: return
+ var awardees by remember { mutableStateOf>(listOf()) }
+
+ Text(text = stringResource(R.string.award_granted_to))
+
+ LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } }
+
+ FlowRow(modifier = Modifier.padding(top = 5.dp)) {
+ awardees.take(100).forEach { user ->
+ Row(
+ modifier =
+ Modifier
+ .size(size = Size35dp)
+ .clickable { nav("User/${user.pubkeyHex}") },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ClickableUserPicture(
+ baseUser = user,
+ accountViewModel = accountViewModel,
+ size = Size35dp,
+ )
+ }
+ }
+
+ if (awardees.size > 100) {
+ Text(" and ${awardees.size - 100} others", maxLines = 1)
+ }
+ }
+
+ note.replyTo?.firstOrNull()?.let {
+ NoteCompose(
+ it,
+ modifier = Modifier,
+ isBoostedNote = false,
+ isQuotedNote = true,
+ unPackReply = false,
+ parentBackgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt
new file mode 100644
index 000000000..6d586ca52
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
+import com.vitorpamplona.amethyst.ui.note.showAmount
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.SmallBorder
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.events.ClassifiedsEvent
+
+@Composable
+fun RenderClassifieds(
+ noteEvent: ClassifiedsEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val image = remember(noteEvent) { noteEvent.image() }
+ val title = remember(noteEvent) { noteEvent.title() }
+ val summary =
+ remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
+ val price = remember(noteEvent) { noteEvent.price() }
+ val location = remember(noteEvent) { noteEvent.location() }
+
+ Row(
+ modifier =
+ Modifier
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ),
+ ) {
+ Column {
+ Row {
+ image?.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.preview_card_image_for,
+ it,
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ?: DefaultImageHeader(note, accountViewModel)
+ }
+
+ Row(
+ Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ price?.let {
+ val priceTag =
+ remember(noteEvent) {
+ val newAmount =
+ price.amount.toBigDecimalOrNull()?.let { showAmount(it) }
+ ?: price.amount
+
+ if (price.frequency != null && price.currency != null) {
+ "$newAmount ${price.currency}/${price.frequency}"
+ } else if (price.currency != null) {
+ "$newAmount ${price.currency}"
+ } else {
+ newAmount
+ }
+ }
+
+ Text(
+ text = priceTag,
+ maxLines = 1,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ remember {
+ Modifier
+ .clip(SmallBorder)
+ .padding(start = 5.dp)
+ },
+ )
+ }
+ }
+
+ if (summary != null || location != null) {
+ Row(
+ Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ summary?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ color = Color.Gray,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = DoubleVertSpacer)
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt
new file mode 100644
index 000000000..d3d29c557
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt
@@ -0,0 +1,394 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.distinctUntilChanged
+import androidx.lifecycle.map
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.AddressableNote
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
+import com.vitorpamplona.amethyst.ui.note.LikeReaction
+import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
+import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.ZapReaction
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
+import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.Size25dp
+import com.vitorpamplona.amethyst.ui.theme.Size35dp
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.StdPadding
+import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.Participant
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import java.util.Locale
+
+@Composable
+fun CommunityHeader(
+ baseNote: AddressableNote,
+ showBottomDiviser: Boolean,
+ sendToCommunity: Boolean,
+ modifier: Modifier = StdPadding,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val expanded = remember { mutableStateOf(false) }
+
+ Column(Modifier.fillMaxWidth()) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ modifier =
+ Modifier.clickable {
+ if (sendToCommunity) {
+ routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) }
+ } else {
+ expanded.value = !expanded.value
+ }
+ },
+ ) {
+ ShortCommunityHeader(
+ baseNote = baseNote,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+
+ if (expanded.value) {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ LongCommunityHeader(
+ baseNote = baseNote,
+ lineModifier = modifier,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+
+ if (showBottomDiviser) {
+ HorizontalDivider(
+ thickness = DividerThickness,
+ )
+ }
+ }
+}
+
+@Composable
+fun LongCommunityHeader(
+ baseNote: AddressableNote,
+ lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteState by baseNote.live().metadata.observeAsState()
+ val noteEvent =
+ remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
+
+ Row(
+ lineModifier,
+ ) {
+ val rulesLabel = stringResource(id = R.string.rules)
+ val summary =
+ remember(noteState) {
+ val subject = noteEvent.subject()?.ifEmpty { null }
+ val body = noteEvent.description()?.ifBlank { null }
+ val rules = noteEvent.rules()?.ifBlank { null }
+
+ if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
+ if (rules == null) {
+ "### $subject\n$body"
+ } else {
+ "### $subject\n$body\n\n### $rulesLabel\n\n$rules"
+ }
+ } else {
+ if (rules == null) {
+ body
+ } else {
+ "$body\n\n$rulesLabel\n$rules"
+ }
+ }
+ }
+
+ Column(
+ Modifier.weight(1f),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val defaultBackground = MaterialTheme.colorScheme.background
+ val background = remember { mutableStateOf(defaultBackground) }
+
+ TranslatableRichTextViewer(
+ content = summary ?: stringResource(id = R.string.community_no_descriptor),
+ canPreview = false,
+ tags = EmptyTagList,
+ backgroundColor = background,
+ id = baseNote.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (summary != null && noteEvent.hasHashtags()) {
+ DisplayUncitedHashtags(
+ hashtags = remember(key1 = noteEvent) { noteEvent.hashtags().toImmutableList() },
+ eventContent = summary,
+ nav = nav,
+ )
+ }
+ }
+
+ Column {
+ Row {
+ Spacer(DoubleHorzSpacer)
+ LongCommunityActionOptions(baseNote, accountViewModel, nav)
+ }
+ }
+ }
+
+ Row(
+ lineModifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(id = R.string.owner),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.width(75.dp),
+ )
+ Spacer(DoubleHorzSpacer)
+ NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
+ Spacer(DoubleHorzSpacer)
+ NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
+ }
+
+ var participantUsers by
+ remember(baseNote) {
+ mutableStateOf>>(
+ persistentListOf(),
+ )
+ }
+
+ LaunchedEffect(key1 = noteState) {
+ val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
+
+ if (participants != null) {
+ accountViewModel.loadParticipants(participants) { newParticipantUsers ->
+ if (
+ newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)
+ ) {
+ participantUsers = newParticipantUsers
+ }
+ }
+ }
+ }
+
+ participantUsers.forEach {
+ Row(
+ lineModifier.clickable { nav("User/${it.second.pubkeyHex}") },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ it.first.role?.let { it1 ->
+ Text(
+ text = it1.capitalize(Locale.ROOT),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.width(75.dp),
+ )
+ }
+ Spacer(DoubleHorzSpacer)
+ ClickableUserPicture(it.second, Size25dp, accountViewModel)
+ Spacer(DoubleHorzSpacer)
+ UsernameDisplay(it.second, remember { Modifier.weight(1f) })
+ }
+ }
+
+ Row(
+ lineModifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(id = R.string.created_at),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.width(75.dp),
+ )
+ Spacer(DoubleHorzSpacer)
+ NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
+ MoreOptionsButton(baseNote, null, accountViewModel, nav)
+ }
+}
+
+@Composable
+fun ShortCommunityHeader(
+ baseNote: AddressableNote,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteState by baseNote.live().metadata.observeAsState()
+ val noteEvent =
+ remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
+
+ val automaticallyShowProfilePicture =
+ remember {
+ accountViewModel.settings.showProfilePictures.value
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ noteEvent.image()?.let {
+ RobohashFallbackAsyncImage(
+ robot = baseNote.idHex,
+ model = it,
+ contentDescription = stringResource(R.string.profile_image),
+ contentScale = ContentScale.Crop,
+ modifier = HeaderPictureModifier,
+ loadProfilePicture = automaticallyShowProfilePicture,
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .padding(start = 10.dp)
+ .height(Size35dp)
+ .weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = remember(noteState) { noteEvent.dTag() },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .height(Size35dp)
+ .padding(start = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ShortCommunityActionOptions(baseNote, accountViewModel, nav)
+ }
+ }
+}
+
+@Composable
+private fun ShortCommunityActionOptions(
+ note: AddressableNote,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ Spacer(modifier = StdHorzSpacer)
+ LikeReaction(
+ baseNote = note,
+ grayTint = MaterialTheme.colorScheme.onSurface,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ Spacer(modifier = StdHorzSpacer)
+ ZapReaction(
+ baseNote = note,
+ grayTint = MaterialTheme.colorScheme.onSurface,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+
+ WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
+ if (!isFollowing) {
+ Spacer(modifier = StdHorzSpacer)
+ JoinCommunityButton(accountViewModel, note, nav)
+ }
+ }
+}
+
+@Composable
+private fun LongCommunityActionOptions(
+ note: AddressableNote,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
+ if (isFollowing) {
+ LeaveCommunityButton(accountViewModel, note, nav)
+ }
+ }
+}
+
+@Composable
+fun WatchAddressableNoteFollows(
+ note: AddressableNote,
+ accountViewModel: AccountViewModel,
+ onFollowChanges: @Composable (Boolean) -> Unit,
+) {
+ val showFollowingMark by
+ remember {
+ accountViewModel.userFollows
+ .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false }
+ .distinctUntilChanged()
+ }
+ .observeAsState(false)
+
+ onFollowChanges(showFollowingMark)
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt
new file mode 100644
index 000000000..2e767e945
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt
@@ -0,0 +1,309 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+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.unit.dp
+import androidx.lifecycle.distinctUntilChanged
+import androidx.lifecycle.map
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
+import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
+import com.vitorpamplona.amethyst.ui.note.elements.AddButton
+import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
+import com.vitorpamplona.amethyst.ui.note.elements.RemoveButton
+import com.vitorpamplona.amethyst.ui.note.getGradient
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.encoders.ATag
+import com.vitorpamplona.quartz.events.EmojiPackEvent
+import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
+import com.vitorpamplona.quartz.events.EmojiUrl
+import com.vitorpamplona.quartz.events.WikiNoteEvent
+
+@Composable
+fun RenderWikiContent(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? WikiNoteEvent ?: return
+
+ WikiNoteHeader(noteEvent, note, accountViewModel, nav)
+}
+
+@Composable
+private fun WikiNoteHeader(
+ noteEvent: WikiNoteEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val title = remember(noteEvent) { noteEvent.title() }
+ val summary =
+ remember(noteEvent) {
+ noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
+ }
+ val image = remember(noteEvent) { noteEvent.image() }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(top = Size5dp)
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ),
+ ) {
+ Column {
+ val automaticallyShowUrlPreview =
+ remember { accountViewModel.settings.showUrlPreview.value }
+
+ if (automaticallyShowUrlPreview) {
+ image?.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.preview_card_image_for,
+ it,
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ?: DefaultImageHeader(note, accountViewModel)
+ }
+
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, top = 10.dp),
+ )
+ }
+
+ summary?.let {
+ Spacer(modifier = StdVertSpacer)
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
+ color = Color.Gray,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+public fun RenderEmojiPack(
+ baseNote: Note,
+ actionable: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ onClick: ((EmojiUrl) -> Unit)? = null,
+) {
+ val noteEvent by
+ baseNote
+ .live()
+ .metadata
+ .map { it.note.event }
+ .distinctUntilChanged()
+ .observeAsState(baseNote.event)
+
+ if (noteEvent == null || noteEvent !is EmojiPackEvent) return
+
+ (noteEvent as? EmojiPackEvent)?.let {
+ RenderEmojiPack(
+ noteEvent = it,
+ baseNote = baseNote,
+ actionable = actionable,
+ backgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ onClick = onClick,
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+public fun RenderEmojiPack(
+ noteEvent: EmojiPackEvent,
+ baseNote: Note,
+ actionable: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ onClick: ((EmojiUrl) -> Unit)? = null,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() }
+
+ val emojisToShow =
+ if (expanded) {
+ allEmojis
+ } else {
+ allEmojis.take(60)
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = remember(noteEvent) { "#${noteEvent.dTag()}" },
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .weight(1F)
+ .padding(5.dp),
+ textAlign = TextAlign.Center,
+ )
+
+ if (actionable) {
+ EmojiListOptions(accountViewModel, baseNote)
+ }
+ }
+
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
+ FlowRow(modifier = Modifier.padding(top = 5.dp)) {
+ emojisToShow.forEach { emoji ->
+ if (onClick != null) {
+ IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) {
+ AsyncImage(
+ model = emoji.url,
+ contentDescription = null,
+ modifier = Size35Modifier,
+ )
+ }
+ } else {
+ Box(
+ modifier = Size35Modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = emoji.url,
+ contentDescription = null,
+ modifier = Size35Modifier,
+ )
+ }
+ }
+ }
+ }
+
+ if (allEmojis.size > 60 && !expanded) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .background(getGradient(backgroundColor)),
+ ) {
+ ShowMoreButton { expanded = !expanded }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmojiListOptions(
+ accountViewModel: AccountViewModel,
+ emojiPackNote: Note,
+) {
+ LoadAddressableNote(
+ aTag =
+ ATag(
+ EmojiPackSelectionEvent.KIND,
+ accountViewModel.userProfile().pubkeyHex,
+ "",
+ null,
+ ),
+ accountViewModel,
+ ) {
+ it?.let { usersEmojiList ->
+ val hasAddedThis by
+ remember {
+ usersEmojiList
+ .live()
+ .metadata
+ .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) }
+ .distinctUntilChanged()
+ }
+ .observeAsState()
+
+ Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") {
+ if (it != true) {
+ AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) }
+ } else {
+ RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt
new file mode 100644
index 000000000..cda8eda50
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import com.vitorpamplona.amethyst.commons.BaseMediaContent
+import com.vitorpamplona.amethyst.commons.MediaUrlImage
+import com.vitorpamplona.amethyst.commons.MediaUrlVideo
+import com.vitorpamplona.amethyst.commons.RichTextParser
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.events.FileHeaderEvent
+
+@Composable
+fun FileHeaderDisplay(
+ note: Note,
+ roundedCorner: Boolean,
+ accountViewModel: AccountViewModel,
+) {
+ val event = (note.event as? FileHeaderEvent) ?: return
+ val fullUrl = event.url() ?: return
+
+ val content by
+ remember(note) {
+ val blurHash = event.blurhash()
+ val hash = event.hash()
+ val dimensions = event.dimensions()
+ val description = event.content.ifEmpty { null } ?: event.alt()
+ val isImage = RichTextParser.isImageUrl(fullUrl)
+ val uri = note.toNostrUri()
+
+ mutableStateOf(
+ if (isImage) {
+ MediaUrlImage(
+ url = fullUrl,
+ description = description,
+ hash = hash,
+ blurhash = blurHash,
+ dim = dimensions,
+ uri = uri,
+ )
+ } else {
+ MediaUrlVideo(
+ url = fullUrl,
+ description = description,
+ hash = hash,
+ dim = dimensions,
+ uri = uri,
+ authorName = note.author?.toBestDisplayName(),
+ )
+ },
+ )
+ }
+
+ SensitivityWarning(note = note, accountViewModel = accountViewModel) {
+ ZoomableContentView(
+ content = content,
+ roundedCorner = roundedCorner,
+ accountViewModel = accountViewModel,
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt
new file mode 100644
index 000000000..d28d51d0f
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import com.vitorpamplona.amethyst.commons.BaseMediaContent
+import com.vitorpamplona.amethyst.commons.MediaLocalImage
+import com.vitorpamplona.amethyst.commons.MediaLocalVideo
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.LoadNote
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
+import java.io.File
+
+@Composable
+fun FileStorageHeaderDisplay(
+ baseNote: Note,
+ roundedCorner: Boolean,
+ accountViewModel: AccountViewModel,
+) {
+ val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
+ val dataEventId = eventHeader.dataEventId() ?: return
+
+ LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
+ if (contentNote != null) {
+ ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
+ }
+ }
+}
+
+@Composable
+private fun ObserverAndRenderNIP95(
+ header: Note,
+ content: Note,
+ roundedCorner: Boolean,
+ accountViewModel: AccountViewModel,
+) {
+ val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
+
+ val appContext = LocalContext.current.applicationContext
+
+ val noteState by content.live().metadata.observeAsState()
+
+ val content by
+ remember(noteState) {
+ // Creates a new object when the event arrives to force an update of the image.
+ val note = noteState?.note
+ val uri = header.toNostrUri()
+ val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) }
+ val blurHash = eventHeader.blurhash()
+ val dimensions = eventHeader.dimensions()
+ val description = eventHeader.alt() ?: eventHeader.content
+ val mimeType = eventHeader.mimeType()
+
+ val newContent =
+ if (mimeType?.startsWith("image") == true) {
+ MediaLocalImage(
+ localFile = localDir,
+ mimeType = mimeType,
+ description = description,
+ dim = dimensions,
+ blurhash = blurHash,
+ isVerified = true,
+ uri = uri,
+ )
+ } else {
+ MediaLocalVideo(
+ localFile = localDir,
+ mimeType = mimeType,
+ description = description,
+ dim = dimensions,
+ isVerified = true,
+ uri = uri,
+ authorName = header.author?.toBestDisplayName(),
+ )
+ }
+
+ mutableStateOf(newContent)
+ }
+
+ Crossfade(targetState = content) {
+ if (it != null) {
+ SensitivityWarning(note = header, accountViewModel = accountViewModel) {
+ ZoomableContentView(
+ content = it,
+ roundedCorner = roundedCorner,
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt
new file mode 100644
index 000000000..9be1ef012
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt
@@ -0,0 +1,386 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.AddressableNote
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.ClickableUrl
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
+import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.GitIssueEvent
+import com.vitorpamplona.quartz.events.GitPatchEvent
+import com.vitorpamplona.quartz.events.GitRepositoryEvent
+import com.vitorpamplona.quartz.events.TextNoteEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun RenderGitPatchEvent(
+ baseNote: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val event = baseNote.event as? GitPatchEvent ?: return
+
+ RenderGitPatchEvent(
+ event,
+ baseNote,
+ makeItShort,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
+}
+
+@Composable
+private fun RenderShortRepositoryHeader(
+ baseNote: AddressableNote,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteState by baseNote.live().metadata.observeAsState()
+ val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return
+
+ Column(
+ modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
+ ) {
+ val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() }
+ Text(
+ text = stringResource(id = R.string.git_repository, title),
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ noteEvent.description()?.let {
+ Spacer(modifier = DoubleVertSpacer)
+ Text(
+ text = it,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RenderGitPatchEvent(
+ noteEvent: GitPatchEvent,
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val repository = remember(noteEvent) { noteEvent.repository() }
+
+ if (repository != null) {
+ LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) {
+ if (it != null) {
+ RenderShortRepositoryHeader(it, accountViewModel, nav)
+ Spacer(modifier = DoubleVertSpacer)
+ }
+ }
+ }
+
+ LoadDecryptedContent(note, accountViewModel) { body ->
+ val eventContent by
+ remember(note.event) {
+ derivedStateOf {
+ val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
+
+ if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
+ "### $subject\n$body"
+ } else {
+ body
+ }
+ }
+ }
+
+ val isAuthorTheLoggedUser =
+ remember(note.event) { accountViewModel.isLoggedUser(note.author) }
+
+ if (makeItShort && isAuthorTheLoggedUser) {
+ Text(
+ text = eventContent,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ SensitivityWarning(
+ note = note,
+ accountViewModel = accountViewModel,
+ ) {
+ val modifier = remember(note) { Modifier.fillMaxWidth() }
+ val tags =
+ remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ TranslatableRichTextViewer(
+ content = eventContent,
+ canPreview = canPreview && !makeItShort,
+ modifier = modifier,
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (note.event?.hasHashtags() == true) {
+ val hashtags =
+ remember(note.event) {
+ note.event?.hashtags()?.toImmutableList() ?: persistentListOf()
+ }
+ DisplayUncitedHashtags(hashtags, eventContent, nav)
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderGitIssueEvent(
+ baseNote: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val event = baseNote.event as? GitIssueEvent ?: return
+
+ RenderGitIssueEvent(
+ event,
+ baseNote,
+ makeItShort,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
+}
+
+@Composable
+private fun RenderGitIssueEvent(
+ noteEvent: GitIssueEvent,
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val repository = remember(noteEvent) { noteEvent.repository() }
+
+ if (repository != null) {
+ LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) {
+ if (it != null) {
+ RenderShortRepositoryHeader(it, accountViewModel, nav)
+ Spacer(modifier = DoubleVertSpacer)
+ }
+ }
+ }
+
+ LoadDecryptedContent(note, accountViewModel) { body ->
+ val eventContent by
+ remember(note.event) {
+ derivedStateOf {
+ val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
+
+ if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
+ "### $subject\n$body"
+ } else {
+ body
+ }
+ }
+ }
+
+ val isAuthorTheLoggedUser =
+ remember(note.event) { accountViewModel.isLoggedUser(note.author) }
+
+ if (makeItShort && isAuthorTheLoggedUser) {
+ Text(
+ text = eventContent,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ SensitivityWarning(
+ note = note,
+ accountViewModel = accountViewModel,
+ ) {
+ val modifier = remember(note) { Modifier.fillMaxWidth() }
+ val tags =
+ remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ TranslatableRichTextViewer(
+ content = eventContent,
+ canPreview = canPreview && !makeItShort,
+ modifier = modifier,
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (note.event?.hasHashtags() == true) {
+ val hashtags =
+ remember(note.event) {
+ note.event?.hashtags()?.toImmutableList() ?: persistentListOf()
+ }
+ DisplayUncitedHashtags(hashtags, eventContent, nav)
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderGitRepositoryEvent(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val event = baseNote.event as? GitRepositoryEvent ?: return
+
+ RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav)
+}
+
+@Composable
+private fun RenderGitRepositoryEvent(
+ noteEvent: GitRepositoryEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() }
+ val summary = remember(noteEvent) { noteEvent.description() }
+ val web = remember(noteEvent) { noteEvent.web() }
+ val clone = remember(noteEvent) { noteEvent.clone() }
+
+ Row(
+ modifier =
+ Modifier
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ).padding(Size10dp),
+ ) {
+ Column {
+ Text(
+ text = stringResource(id = R.string.git_repository, title),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ summary?.let {
+ Text(
+ text = it,
+ modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ HorizontalDivider(thickness = DividerThickness)
+
+ web?.let {
+ Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
+ Text(
+ text = stringResource(id = R.string.git_web_address),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = StdHorzSpacer)
+ ClickableUrl(
+ url = it,
+ urlText = it.removePrefix("https://").removePrefix("http://"),
+ )
+ }
+ }
+
+ clone?.let {
+ Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
+ Text(
+ text = stringResource(id = R.string.git_clone_address),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = StdHorzSpacer)
+ ClickableUrl(
+ url = it,
+ urlText = it.removePrefix("https://").removePrefix("http://"),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt
new file mode 100644
index 000000000..db227ced8
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt
@@ -0,0 +1,202 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+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.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.lifecycle.distinctUntilChanged
+import androidx.lifecycle.map
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.components.ClickableUrl
+import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
+import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.encoders.ATag
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.HighlightEvent
+import com.vitorpamplona.quartz.events.LongTextNoteEvent
+import java.net.URL
+
+@Composable
+fun RenderHighlight(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" }
+ val author = remember { (note.event as? HighlightEvent)?.author() }
+ val url = remember { (note.event as? HighlightEvent)?.inUrl() }
+ val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() }
+
+ DisplayHighlight(
+ highlight = quote,
+ authorHex = author,
+ url = url,
+ postAddress = postHex,
+ makeItShort = makeItShort,
+ canPreview = canPreview,
+ backgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+}
+
+@Composable
+fun DisplayHighlight(
+ highlight: String,
+ authorHex: String?,
+ url: String?,
+ postAddress: ATag?,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val quote =
+ remember {
+ highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" }
+ }
+
+ TranslatableRichTextViewer(
+ quote,
+ canPreview = canPreview && !makeItShort,
+ remember { Modifier.fillMaxWidth() },
+ EmptyTagList,
+ backgroundColor,
+ id = quote,
+ accountViewModel,
+ nav,
+ )
+
+ DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun DisplayQuoteAuthor(
+ authorHex: String,
+ url: String?,
+ postAddress: ATag?,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) }
+
+ if (userBase == null) {
+ LaunchedEffect(Unit) {
+ accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase ->
+ userBase = newUserBase
+ }
+ }
+ }
+
+ val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current)
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spaceWidth),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ userBase?.let { userBase ->
+ val userMetadata by userBase.live().userMetadataInfo.observeAsState()
+
+ CreateClickableTextWithEmoji(
+ clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: userBase.pubkeyDisplayHex(),
+ maxLines = 1,
+ route = "User/${userBase.pubkeyHex}",
+ nav = nav,
+ tags = userMetadata?.tags,
+ )
+ }
+
+ url?.let { url -> LoadAndDisplayUrl(url) }
+
+ postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) }
+ }
+}
+
+@Composable
+fun LoadAndDisplayUrl(url: String) {
+ val validatedUrl =
+ remember {
+ try {
+ URL(url)
+ } catch (e: Exception) {
+ Log.w("Note Compose", "Invalid URI: $url")
+ null
+ }
+ }
+
+ validatedUrl?.host?.let { host ->
+ Text(remember { "-" }, maxLines = 1)
+ ClickableUrl(urlText = host, url = url)
+ }
+}
+
+@Composable
+private fun LoadAndDisplayPost(
+ postAddress: ATag,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ LoadAddressableNote(aTag = postAddress, accountViewModel) {
+ it?.let { note ->
+ val noteEvent by
+ note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event)
+
+ val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() }
+
+ title?.let {
+ Text(remember { "-" }, maxLines = 1)
+ ClickableText(
+ text = AnnotatedString(title),
+ onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } },
+ style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt
new file mode 100644
index 000000000..36f25aaed
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.components.VideoView
+import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
+import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
+import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.events.LiveActivitiesEvent
+import com.vitorpamplona.quartz.events.Participant
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import java.util.Locale
+
+@Composable
+fun RenderLiveActivityEvent(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ Row(modifier = Modifier.padding(top = 5.dp)) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav)
+ }
+ }
+}
+
+@Composable
+fun RenderLiveActivityEventInner(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
+
+ val eventUpdates by baseNote.live().metadata.observeAsState()
+
+ val media = remember(eventUpdates) { noteEvent.streaming() }
+ val cover = remember(eventUpdates) { noteEvent.image() }
+ val subject = remember(eventUpdates) { noteEvent.title() }
+ val content = remember(eventUpdates) { noteEvent.summary() }
+ val participants = remember(eventUpdates) { noteEvent.participants() }
+ val status = remember(eventUpdates) { noteEvent.status() }
+ val starts = remember(eventUpdates) { noteEvent.starts() }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .padding(vertical = 5.dp)
+ .fillMaxWidth(),
+ ) {
+ subject?.let {
+ Text(
+ text = it,
+ fontWeight = FontWeight.Bold,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ Spacer(modifier = StdHorzSpacer)
+
+ Crossfade(targetState = status, label = "RenderLiveActivityEventInner") {
+ when (it) {
+ LiveActivitiesEvent.STATUS_LIVE -> {
+ media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } }
+ }
+
+ LiveActivitiesEvent.STATUS_PLANNED -> {
+ ScheduledFlag(starts)
+ }
+ }
+ }
+ }
+
+ var participantUsers by remember {
+ mutableStateOf>>(
+ persistentListOf(),
+ )
+ }
+
+ LaunchedEffect(key1 = eventUpdates) {
+ accountViewModel.loadParticipants(participants) { newParticipantUsers ->
+ if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
+ participantUsers = newParticipantUsers
+ }
+ }
+ }
+
+ media?.let { media ->
+ if (status == LiveActivitiesEvent.STATUS_LIVE) {
+ CheckIfUrlIsOnline(media, accountViewModel) { isOnline ->
+ if (isOnline) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ VideoView(
+ videoUri = media,
+ title = subject,
+ artworkUri = cover,
+ authorName = baseNote.author?.toBestDisplayName(),
+ roundedCorner = true,
+ accountViewModel = accountViewModel,
+ nostrUriCallback = "nostr:${baseNote.toNEvent()}",
+ )
+ }
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .padding(10.dp)
+ .height(100.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.live_stream_is_offline),
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+ } else if (status == LiveActivitiesEvent.STATUS_ENDED) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .padding(10.dp)
+ .height(100.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.live_stream_has_ended),
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+
+ participantUsers.forEach {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .padding(vertical = 5.dp)
+ .clickable { nav("User/${it.second.pubkeyHex}") },
+ ) {
+ ClickableUserPicture(it.second, 25.dp, accountViewModel)
+ Spacer(StdHorzSpacer)
+ UsernameDisplay(it.second, Modifier.weight(1f))
+ Spacer(StdHorzSpacer)
+ it.first.role?.let {
+ Text(
+ text = it.capitalize(Locale.ROOT),
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt
new file mode 100644
index 000000000..f4584efbe
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt
@@ -0,0 +1,133 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.events.LongTextNoteEvent
+
+@Composable
+fun RenderLongFormContent(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? LongTextNoteEvent ?: return
+
+ LongFormHeader(noteEvent, note, accountViewModel)
+}
+
+@Composable
+private fun LongFormHeader(
+ noteEvent: LongTextNoteEvent,
+ note: Note,
+ accountViewModel: AccountViewModel,
+) {
+ val image = remember(noteEvent) { noteEvent.image() }
+ val title = remember(noteEvent) { noteEvent.title() }
+ val summary =
+ remember(noteEvent) {
+ noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(top = Size5dp)
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ),
+ ) {
+ Column {
+ val automaticallyShowUrlPreview =
+ remember { accountViewModel.settings.showUrlPreview.value }
+
+ if (automaticallyShowUrlPreview) {
+ image?.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.preview_card_image_for,
+ it,
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ?: DefaultImageHeader(note, accountViewModel)
+ }
+
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, top = 10.dp),
+ )
+ }
+
+ summary?.let {
+ Spacer(modifier = StdVertSpacer)
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
+ color = Color.Gray,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt
new file mode 100644
index 000000000..0231b8d60
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt
@@ -0,0 +1,265 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.model.Bundle
+import com.vitorpamplona.amethyst.model.FhirElementDatabase
+import com.vitorpamplona.amethyst.model.LensSpecification
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.Patient
+import com.vitorpamplona.amethyst.model.Practitioner
+import com.vitorpamplona.amethyst.model.Resource
+import com.vitorpamplona.amethyst.model.VisionPrescription
+import com.vitorpamplona.amethyst.model.findReferenceInDb
+import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.quartz.events.Event
+import com.vitorpamplona.quartz.events.FhirResourceEvent
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.text.DecimalFormat
+import java.text.NumberFormat
+
+@Preview
+@Composable
+fun RenderEyeGlassesPrescriptionPreview() {
+ val prescriptionEvent =
+ Event.fromJson(
+ "{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}",
+ ) as FhirResourceEvent
+
+ RenderFhirResource(prescriptionEvent)
+}
+
+@Composable
+fun RenderFhirResource(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val event = baseNote.event as? FhirResourceEvent ?: return
+
+ RenderFhirResource(event)
+}
+
+@Composable
+fun RenderFhirResource(event: FhirResourceEvent) {
+ val state by produceState(initialValue = FhirElementDatabase(), key1 = event) {
+ withContext(Dispatchers.Default) {
+ parseResourceBundleOrNull(event.content)?.let {
+ value = it
+ }
+ }
+ }
+
+ state.baseResource?.let { resource ->
+ when (resource) {
+ is Bundle -> {
+ val vision = resource.entry.filterIsInstance(VisionPrescription::class.java)
+
+ vision.firstOrNull()?.let {
+ RenderEyeGlassesPrescription(it, state.localDb)
+ }
+ }
+ is VisionPrescription -> {
+ RenderEyeGlassesPrescription(resource, state.localDb)
+ }
+ else -> {
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderEyeGlassesPrescription(
+ visionPrescription: VisionPrescription,
+ db: ImmutableMap,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = Size10dp),
+ ) {
+ val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" }
+ val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" }
+
+ Text(
+ "Eyeglasses Prescription",
+ modifier = Modifier.padding(4.dp).fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(StdVertSpacer)
+
+ visionPrescription.patient?.reference?.let {
+ val patient = findReferenceInDb(it, db) as? Patient
+
+ patient?.name?.firstOrNull()?.assembleName()?.let {
+ Text(
+ text = "Patient: $it",
+ modifier = Modifier.padding(4.dp).fillMaxWidth(),
+ )
+ }
+ }
+ visionPrescription.status?.let {
+ Text(
+ text = "Status: ${it.capitalize()}",
+ modifier = Modifier.padding(4.dp).fillMaxWidth(),
+ )
+ }
+
+ Spacer(DoubleVertSpacer)
+
+ RenderEyeGlassesPrescriptionHeaderRow()
+ HorizontalDivider(thickness = DividerThickness)
+
+ rightEye?.let {
+ RenderEyeGlassesPrescriptionRow(data = it)
+ HorizontalDivider(thickness = DividerThickness)
+ }
+
+ leftEye?.let {
+ RenderEyeGlassesPrescriptionRow(data = it)
+ HorizontalDivider(thickness = DividerThickness)
+ }
+
+ visionPrescription.prescriber?.reference?.let {
+ val practitioner = findReferenceInDb(it, db) as? Practitioner
+
+ practitioner?.name?.firstOrNull()?.assembleName()?.let {
+ Spacer(DoubleVertSpacer)
+ Text(
+ text = "Signed by: $it",
+ modifier = Modifier.padding(4.dp).fillMaxWidth(),
+ textAlign = TextAlign.Right,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderEyeGlassesPrescriptionHeaderRow() {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = "Eye",
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ VerticalDivider(thickness = DividerThickness)
+ Text(
+ text = "Sph",
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ Text(
+ text = "Cyl",
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ Text(
+ text = "Axis",
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ VerticalDivider(thickness = DividerThickness)
+ Text(
+ text = "Add",
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ }
+}
+
+@Composable
+fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val numberFormat = DecimalFormat("##.00")
+ val integerFormat = DecimalFormat("###")
+
+ Text(
+ text = data.eye?.capitalize() ?: "Unknown",
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ VerticalDivider(thickness = DividerThickness)
+ Text(
+ text = formatOrBlank(data.sphere, numberFormat),
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ Text(
+ text = formatOrBlank(data.cylinder, numberFormat),
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ Text(
+ text = formatOrBlank(data.axis, integerFormat),
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ VerticalDivider(thickness = DividerThickness)
+ Text(
+ text = formatOrBlank(data.add, numberFormat),
+ textAlign = TextAlign.Right,
+ modifier = Modifier.padding(4.dp).weight(1f),
+ )
+ }
+}
+
+fun formatOrBlank(
+ amount: Double?,
+ numberFormat: NumberFormat,
+): String {
+ if (amount == null) return ""
+ if (Math.abs(amount) < 0.01) return ""
+ return numberFormat.format(amount)
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt
new file mode 100644
index 000000000..a5d6c2ae0
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt
@@ -0,0 +1,126 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+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.unit.dp
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
+import com.vitorpamplona.amethyst.ui.note.UserCompose
+import com.vitorpamplona.amethyst.ui.note.getGradient
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.events.PeopleListEvent
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun DisplayPeopleList(
+ baseNote: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = baseNote.event as? PeopleListEvent ?: return
+
+ var members by remember { mutableStateOf>(persistentListOf()) }
+
+ var expanded by remember { mutableStateOf(false) }
+
+ val toMembersShow =
+ if (expanded) {
+ members
+ } else {
+ members.take(3)
+ }
+
+ val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } }
+
+ Text(
+ text = name,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(5.dp),
+ textAlign = TextAlign.Center,
+ )
+
+ LaunchedEffect(Unit) {
+ accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) {
+ members = it
+ }
+ }
+
+ Box {
+ FlowRow(modifier = Modifier.padding(top = 5.dp)) {
+ toMembersShow.forEach { user ->
+ Row(modifier = Modifier.fillMaxWidth()) {
+ UserCompose(
+ user,
+ overallModifier = Modifier,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+
+ if (members.size > 3 && !expanded) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .background(getGradient(backgroundColor)),
+ ) {
+ ShowMoreButton { expanded = !expanded }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt
new file mode 100644
index 000000000..4ab4876bb
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt
@@ -0,0 +1,132 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+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.unit.dp
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.PinIcon
+import com.vitorpamplona.amethyst.ui.note.getGradient
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.PinListEvent
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun RenderPinListEvent(
+ baseNote: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = baseNote.event as? PinListEvent ?: return
+
+ val pins by remember { mutableStateOf(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 = Alignment.CenterVertically,
+ ) {
+ PinIcon(
+ modifier = Size15Modifier,
+ tint = MaterialTheme.colorScheme.onBackground.copy(0.32f),
+ )
+
+ Spacer(modifier = Modifier.width(5.dp))
+
+ TranslatableRichTextViewer(
+ content = pin,
+ canPreview = true,
+ tags = EmptyTagList,
+ backgroundColor = backgroundColor,
+ id = baseNote.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+
+ if (pins.size > 3 && !expanded) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .background(getGradient(backgroundColor)),
+ ) {
+ ShowMoreButton { expanded = !expanded }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt
new file mode 100644
index 000000000..f9bd72547
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.PollNote
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.PollNoteEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun RenderPoll(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? PollNoteEvent ?: return
+ val eventContent = noteEvent.content()
+
+ if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
+ Text(
+ text = eventContent,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ SensitivityWarning(
+ note = note,
+ accountViewModel = accountViewModel,
+ ) {
+ TranslatableRichTextViewer(
+ content = eventContent,
+ canPreview = canPreview && !makeItShort,
+ modifier = remember { Modifier.fillMaxWidth() },
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+
+ PollNote(
+ note,
+ canPreview = canPreview && !makeItShort,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
+ }
+
+ if (noteEvent.hasHashtags()) {
+ val hashtags = remember { noteEvent.hashtags().toImmutableList() }
+ DisplayUncitedHashtags(hashtags, eventContent, nav)
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt
new file mode 100644
index 000000000..84fbc70de
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.encoders.toNpub
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.PrivateDmEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun RenderPrivateMessage(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? PrivateDmEvent ?: return
+
+ val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
+ if (withMe) {
+ LoadDecryptedContent(note, accountViewModel) { eventContent ->
+ val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
+ val isAuthorTheLoggedUser =
+ remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
+
+ val tags =
+ remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ if (makeItShort && isAuthorTheLoggedUser) {
+ Text(
+ text = eventContent,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ SensitivityWarning(
+ note = note,
+ accountViewModel = accountViewModel,
+ ) {
+ TranslatableRichTextViewer(
+ content = eventContent,
+ canPreview = canPreview && !makeItShort,
+ modifier = modifier,
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (noteEvent.hasHashtags()) {
+ val hashtags =
+ remember(note.event?.id()) {
+ note.event?.hashtags()?.toImmutableList() ?: persistentListOf()
+ }
+ DisplayUncitedHashtags(hashtags, eventContent, nav)
+ }
+ }
+ }
+ } else {
+ val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone"
+
+ TranslatableRichTextViewer(
+ stringResource(
+ id = R.string.private_conversation_notification,
+ "@${note.author?.pubkeyNpub()}",
+ "@$recipient",
+ ),
+ canPreview = !makeItShort,
+ Modifier.fillMaxWidth(),
+ EmptyTagList,
+ backgroundColor,
+ id = note.idHex,
+ accountViewModel,
+ nav,
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt
new file mode 100644
index 000000000..d42515700
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+
+@Composable
+fun RenderReaction(
+ note: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ note.replyTo?.lastOrNull()?.let {
+ NoteCompose(
+ it,
+ modifier = Modifier,
+ isBoostedNote = true,
+ unPackReply = false,
+ parentBackgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ // Reposts have trash in their contents.
+ val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: ""
+
+ Text(
+ text = refactorReactionText,
+ maxLines = 1,
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt
new file mode 100644
index 000000000..ea134909e
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt
@@ -0,0 +1,178 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+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.unit.dp
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
+import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
+import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
+import com.vitorpamplona.amethyst.ui.note.AddRelayButton
+import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton
+import com.vitorpamplona.amethyst.ui.note.getGradient
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.events.RelaySetEvent
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun DisplayRelaySet(
+ baseNote: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = baseNote.event as? RelaySetEvent ?: return
+
+ val relays by
+ remember(baseNote) {
+ mutableStateOf(
+ noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(),
+ )
+ }
+
+ var expanded by remember { mutableStateOf(false) }
+
+ val toMembersShow =
+ if (expanded) {
+ relays
+ } else {
+ relays.take(3)
+ }
+
+ val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } }
+
+ val relayDescription by remember { derivedStateOf { noteEvent.description() } }
+
+ Text(
+ text = relayListName,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(5.dp),
+ textAlign = TextAlign.Center,
+ )
+
+ relayDescription?.let {
+ Text(
+ text = it,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(5.dp),
+ textAlign = TextAlign.Center,
+ color = Color.Gray,
+ )
+ }
+
+ Box {
+ Column(modifier = Modifier.padding(top = 5.dp)) {
+ toMembersShow.forEach { relay ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = relay.displayUrl,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .padding(start = 10.dp, bottom = 5.dp)
+ .weight(1f),
+ )
+
+ Column(modifier = Modifier.padding(start = 10.dp)) {
+ RelayOptionsAction(relay.url, accountViewModel, nav)
+ }
+ }
+ }
+ }
+
+ if (relays.size > 3 && !expanded) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .background(getGradient(backgroundColor)),
+ ) {
+ ShowMoreButton { expanded = !expanded }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RelayOptionsAction(
+ relay: String,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState()
+ val isCurrentlyOnTheUsersList by
+ remember(userStateRelayInfo) {
+ derivedStateOf {
+ userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true
+ }
+ }
+
+ var wantsToAddRelay by remember { mutableStateOf("") }
+
+ if (wantsToAddRelay.isNotEmpty()) {
+ NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav)
+ }
+
+ if (isCurrentlyOnTheUsersList) {
+ AddRelayButton { wantsToAddRelay = relay }
+ } else {
+ RemoveRelayButton { wantsToAddRelay = relay }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt
new file mode 100644
index 000000000..d9e3c4197
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+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.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
+
+@Composable
+fun RenderPostApproval(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ if (note.replyTo.isNullOrEmpty()) return
+
+ val noteEvent = note.event as? CommunityPostApprovalEvent ?: return
+
+ Column(Modifier.fillMaxWidth()) {
+ noteEvent.communities().forEach {
+ LoadAddressableNote(it, accountViewModel) {
+ it?.let {
+ NoteCompose(
+ it,
+ parentBackgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+
+ Text(
+ text = stringResource(id = R.string.community_approved_posts),
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(5.dp),
+ textAlign = TextAlign.Center,
+ )
+
+ note.replyTo?.forEach {
+ NoteCompose(
+ it,
+ modifier = MaterialTheme.colorScheme.replyModifier,
+ unPackReply = false,
+ makeItShort = true,
+ isQuotedNote = true,
+ parentBackgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt
new file mode 100644
index 000000000..7075af94c
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.ReportEvent
+
+@Composable
+fun RenderReport(
+ note: Note,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? ReportEvent ?: return
+
+ val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) }
+
+ val reportType =
+ base
+ .map {
+ when (it.reportType) {
+ ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
+ ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity)
+ ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech)
+ ReportEvent.ReportType.SPAM -> stringResource(R.string.spam)
+ ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation)
+ ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior)
+ ReportEvent.ReportType.OTHER -> stringResource(R.string.other)
+ }
+ }
+ .toSet()
+ .joinToString(", ")
+
+ val content =
+ remember {
+ reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "")
+ }
+
+ TranslatableRichTextViewer(
+ content = content,
+ canPreview = true,
+ modifier = Modifier,
+ tags = EmptyTagList,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+
+ note.replyTo?.lastOrNull()?.let {
+ NoteCompose(
+ baseNote = it,
+ isQuotedNote = true,
+ modifier =
+ Modifier
+ .padding(top = 5.dp)
+ .fillMaxWidth()
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ),
+ unPackReply = false,
+ makeItShort = true,
+ parentBackgroundColor = backgroundColor,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt
new file mode 100644
index 000000000..538e4f6a3
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt
@@ -0,0 +1,124 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.TextNoteEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun RenderTextEvent(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ editState: State>,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ LoadDecryptedContent(
+ note,
+ accountViewModel,
+ ) { body ->
+ val eventContent by
+ remember(note.event) {
+ derivedStateOf {
+ val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
+ val newBody =
+ if (editState.value is GenericLoadable.Loaded) {
+ val state =
+ (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow
+ state?.value?.event?.content() ?: body
+ } else {
+ body
+ }
+
+ if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) {
+ "### $subject\n$newBody"
+ } else {
+ newBody
+ }
+ }
+ }
+
+ val isAuthorTheLoggedUser =
+ remember(note.event) { accountViewModel.isLoggedUser(note.author) }
+
+ if (makeItShort && isAuthorTheLoggedUser) {
+ Text(
+ text = eventContent,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ SensitivityWarning(
+ note = note,
+ accountViewModel = accountViewModel,
+ ) {
+ val modifier = remember(note) { Modifier.fillMaxWidth() }
+ val tags =
+ remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ TranslatableRichTextViewer(
+ content = eventContent,
+ canPreview = canPreview && !makeItShort,
+ modifier = modifier,
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (note.event?.hasHashtags() == true) {
+ val hashtags =
+ remember(note.event) {
+ note.event?.hashtags()?.toImmutableList() ?: persistentListOf()
+ }
+ DisplayUncitedHashtags(hashtags, eventContent, nav)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt
new file mode 100644
index 000000000..2920ae174
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt
@@ -0,0 +1,280 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.actions.EditPostView
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
+import com.vitorpamplona.amethyst.ui.components.LoadNote
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.navigation.routeFor
+import com.vitorpamplona.amethyst.ui.note.NoteBody
+import com.vitorpamplona.amethyst.ui.note.observeEdits
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.imageModifier
+import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun RenderTextModificationEvent(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val noteEvent = note.event as? TextNoteModificationEvent ?: return
+ val noteAuthor = note.author ?: return
+
+ val wantsToEditPost =
+ remember {
+ mutableStateOf(false)
+ }
+
+ val isAuthorTheLoggedUser =
+ remember {
+ val authorOfTheOriginalNote =
+ noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it)?.author }
+
+ mutableStateOf(accountViewModel.isLoggedUser(authorOfTheOriginalNote))
+ }
+
+ noteEvent.editedNote()?.let {
+ LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseOriginalNote ->
+ baseOriginalNote?.let {
+ }
+ }
+ }
+
+ Card(
+ modifier = MaterialTheme.colorScheme.imageModifier,
+ ) {
+ Column(Modifier.fillMaxWidth().padding(Size10dp)) {
+ Text(
+ text = stringResource(id = R.string.proposal_to_edit),
+ style =
+ TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+
+ Spacer(modifier = StdVertSpacer)
+
+ noteEvent.summary()?.let {
+ TranslatableRichTextViewer(
+ content = it,
+ canPreview = canPreview && !makeItShort,
+ modifier = Modifier.fillMaxWidth(),
+ tags = EmptyTagList,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ Spacer(modifier = StdVertSpacer)
+ }
+
+ noteEvent.editedNote()?.let {
+ LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseNote ->
+ baseNote?.let {
+ val noteState by baseNote.live().metadata.observeAsState()
+
+ val editStateOriginalNote =
+ observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
+
+ val editState =
+ remember(note) {
+ derivedStateOf {
+ val loadable =
+ editStateOriginalNote.value as? GenericLoadable.Loaded
+
+ val state = EditState()
+
+ val latestChangeByAuthor =
+ if (loadable != null && loadable.loaded.hasModificationsToShow()) {
+ loadable.loaded.latestBefore(note.createdAt())
+ } else {
+ null
+ }
+
+ state.updateModifications(
+ listOfNotNull(
+ latestChangeByAuthor,
+ note,
+ ),
+ )
+
+ GenericLoadable.Loaded(state)
+ }
+ }
+
+ LaunchedEffect(key1 = noteState) {
+ val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author)
+
+ if (isAuthorTheLoggedUser.value != newAuthor) {
+ isAuthorTheLoggedUser.value = newAuthor
+ }
+ }
+
+ Column(
+ modifier =
+ MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp)
+ .clickable {
+ routeFor(
+ baseNote,
+ accountViewModel.userProfile(),
+ )?.let { nav(it) }
+ },
+ ) {
+ NoteBody(
+ baseNote = baseNote,
+ showAuthorPicture = true,
+ unPackReply = false,
+ makeItShort = false,
+ canPreview = true,
+ showSecondRow = false,
+ backgroundColor = backgroundColor,
+ editState = editState,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+
+ if (wantsToEditPost.value) {
+ EditPostView(
+ onClose = {
+ wantsToEditPost.value = false
+ },
+ edit = baseNote,
+ versionLookingAt = note,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (isAuthorTheLoggedUser.value) {
+ Spacer(modifier = StdVertSpacer)
+
+ Button(
+ onClick = { wantsToEditPost.value = true },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = stringResource(id = R.string.accept_the_suggestion))
+ }
+ }
+ }
+ }
+}
+
+@Stable
+class EditState() {
+ private var modificationsList: List = persistentListOf()
+ private var modificationToShowIndex: Int = -1
+
+ val modificationToShow: MutableState = mutableStateOf(null)
+ val showingVersion: MutableState = mutableStateOf(0)
+
+ fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty()
+
+ fun isOriginal(): Boolean = modificationToShowIndex < 0
+
+ fun isLatest(): Boolean = modificationToShowIndex == modificationsList.lastIndex
+
+ fun originalVersionId() = 0
+
+ fun lastVersionId() = modificationsList.size
+
+ fun versionId() = modificationToShowIndex + 1
+
+ fun latest() = modificationsList.lastOrNull()
+
+ fun latestBefore(createdAt: Long?): Note? {
+ if (createdAt == null) return latest()
+ return modificationsList.lastOrNull { (it.createdAt() ?: Long.MAX_VALUE) < createdAt }
+ }
+
+ fun nextModification() {
+ if (modificationToShowIndex < 0) {
+ modificationToShowIndex = 0
+ modificationToShow.value = modificationsList.getOrNull(0)
+ } else {
+ modificationToShowIndex++
+ if (modificationToShowIndex >= modificationsList.size) {
+ modificationToShowIndex = -1
+ modificationToShow.value = null
+ } else {
+ modificationToShow.value = modificationsList.getOrNull(modificationToShowIndex)
+ }
+ }
+
+ showingVersion.value = versionId()
+ }
+
+ fun updateModifications(newModifications: List) {
+ if (modificationsList != newModifications) {
+ modificationsList = newModifications
+
+ if (newModifications.isEmpty()) {
+ modificationToShow.value = null
+ modificationToShowIndex = -1
+ } else {
+ modificationToShowIndex = newModifications.lastIndex
+ modificationToShow.value = newModifications.last()
+ }
+ }
+
+ showingVersion.value = versionId()
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt
new file mode 100644
index 000000000..4e532de82
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt
@@ -0,0 +1,188 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.types
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.BaseMediaContent
+import com.vitorpamplona.amethyst.commons.MediaUrlImage
+import com.vitorpamplona.amethyst.commons.MediaUrlVideo
+import com.vitorpamplona.amethyst.commons.RichTextParser
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
+import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.imageModifier
+import com.vitorpamplona.quartz.events.EmptyTagList
+import com.vitorpamplona.quartz.events.VideoEvent
+import com.vitorpamplona.quartz.events.toImmutableListOfLists
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun VideoDisplay(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: MutableState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val event = (note.event as? VideoEvent) ?: return
+ val fullUrl = event.url() ?: return
+
+ val title = event.title()
+ val summary = event.content.ifBlank { null }?.takeIf { title != it }
+ val image = event.thumb() ?: event.image()
+ val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be")
+ val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
+
+ val content by
+ remember(note) {
+ val blurHash = event.blurhash()
+ val hash = event.hash()
+ val dimensions = event.dimensions()
+ val description = event.content.ifBlank { null } ?: event.alt()
+ val isImage = RichTextParser.isImageUrl(fullUrl)
+ val uri = note.toNostrUri()
+
+ mutableStateOf(
+ if (isImage) {
+ MediaUrlImage(
+ url = fullUrl,
+ description = description,
+ hash = hash,
+ blurhash = blurHash,
+ dim = dimensions,
+ uri = uri,
+ )
+ } else {
+ MediaUrlVideo(
+ url = fullUrl,
+ description = description,
+ hash = hash,
+ dim = dimensions,
+ uri = uri,
+ authorName = note.author?.toBestDisplayName(),
+ artworkUri = event.thumb() ?: event.image(),
+ )
+ },
+ )
+ }
+
+ SensitivityWarning(note = note, accountViewModel = accountViewModel) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 5.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (isYouTube) {
+ val uri = LocalUriHandler.current
+ Row(
+ modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } },
+ ) {
+ image?.let {
+ AsyncImage(
+ model = it,
+ contentDescription =
+ stringResource(
+ R.string.preview_card_image_for,
+ it,
+ ),
+ contentScale = ContentScale.FillWidth,
+ modifier = MaterialTheme.colorScheme.imageModifier,
+ )
+ }
+ ?: DefaultImageHeader(note, accountViewModel)
+ }
+ } else {
+ ZoomableContentView(
+ content = content,
+ roundedCorner = true,
+ accountViewModel = accountViewModel,
+ )
+ }
+
+ title?.let {
+ Text(
+ text = it,
+ fontWeight = FontWeight.Bold,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 5.dp),
+ )
+ }
+
+ summary?.let {
+ TranslatableRichTextViewer(
+ content = it,
+ canPreview = canPreview && !makeItShort,
+ modifier = Modifier.fillMaxWidth(),
+ tags = tags,
+ backgroundColor = backgroundColor,
+ id = note.idHex,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ )
+ }
+
+ if (event.hasHashtags()) {
+ Row(
+ Modifier.fillMaxWidth(),
+ ) {
+ DisplayUncitedHashtags(
+ remember(event) { event.hashtags().toImmutableList() },
+ summary ?: "",
+ nav,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
index 2d24b634c..8bea5062e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
@@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
-fun RefresheableCardView(
+fun RefreshableCardView(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt
index bf5720b83..89c5c6b52 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -54,7 +54,7 @@ fun RefreshingChatroomFeedView(
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
- RefresheableView(viewModel, enablePullRefresh) {
+ RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderChatroomFeedView(
viewModel,
@@ -150,7 +150,7 @@ fun NewSubject(note: Note) {
@Composable
fun NewSubject(newSubject: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Divider(
+ HorizontalDivider(
modifier = Modifier.weight(1f),
)
Text(
@@ -159,7 +159,7 @@ fun NewSubject(newSubject: String) {
fontSize = Font14SP,
modifier = HalfPadding,
)
- Divider(
+ HorizontalDivider(
modifier = Modifier.weight(1f),
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt
index ffa1a6f7e..43fa223fc 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt
@@ -45,7 +45,7 @@ fun ChatroomListFeedView(
nav: (String) -> Unit,
markAsRead: MutableState,
) {
- RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
+ RefresheableBox(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
}
@Composable
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt
index ea9d5ac64..4061e144c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt
@@ -27,8 +27,10 @@ import kotlinx.collections.immutable.ImmutableList
sealed class FeedState {
object Loading : FeedState()
- class Loaded(val feed: MutableState>, val showHidden: MutableState) :
- FeedState()
+ class Loaded(
+ val feed: MutableState>,
+ val showHidden: MutableState,
+ ) : FeedState()
object Empty : FeedState()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
index f1064990c..4330347e0 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
@@ -27,6 +27,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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -56,7 +57,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
-import kotlin.time.ExperimentalTime
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun RefresheableFeedView(
@@ -67,13 +68,15 @@ fun RefresheableFeedView(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- RefresheableView(viewModel, enablePullRefresh) {
- SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav)
+ RefresheableBox(viewModel, enablePullRefresh) {
+ SaveableFeedState(viewModel, scrollStateKey) { listState ->
+ RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
+ }
}
}
@Composable
-fun RefresheableView(
+fun RefresheableBox(
viewModel: InvalidatableViewModel,
enablePullRefresh: Boolean = true,
content: @Composable () -> Unit,
@@ -108,19 +111,6 @@ fun RefresheableView(
}
}
-@Composable
-private fun SaveableFeedState(
- viewModel: FeedViewModel,
- routeForLastRead: String?,
- scrollStateKey: String? = null,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- SaveableFeedState(viewModel, scrollStateKey) { listState ->
- RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
- }
-}
-
@Composable
fun SaveableFeedState(
viewModel: FeedViewModel,
@@ -158,12 +148,16 @@ fun SaveableGridFeedState(
}
@Composable
-private fun RenderFeed(
+fun RenderFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String?,
+ onLoaded: @Composable (FeedState.Loaded) -> Unit = { FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav) },
+ onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
+ onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } },
+ onLoading: @Composable () -> Unit = { LoadingFeed() },
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
@@ -172,24 +166,10 @@ private fun RenderFeed(
animationSpec = tween(durationMillis = 100),
) { state ->
when (state) {
- is FeedState.Empty -> {
- FeedEmpty { viewModel.invalidateData() }
- }
- is FeedState.FeedError -> {
- FeedError(state.errorMessage) { viewModel.invalidateData() }
- }
- is FeedState.Loaded -> {
- FeedLoaded(
- state = state,
- listState = listState,
- routeForLastRead = routeForLastRead,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
- is FeedState.Loading -> {
- LoadingFeed()
- }
+ is FeedState.Empty -> onEmpty()
+ is FeedState.FeedError -> onError(state.errorMessage)
+ is FeedState.Loaded -> onLoaded(state)
+ is FeedState.Loading -> onLoading()
}
}
}
@@ -224,7 +204,7 @@ private fun WatchScrollToTop(
}
}
-@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
@@ -251,6 +231,20 @@ private fun FeedLoaded(
nav = nav,
)
}
+
+ /*var see by
+ remember {
+ mutableStateOf(false)
+ }
+
+ if (see) {
+ } else {
+ Row(defaultModifier) {
+ Button(onClick = { see = true }) {
+ Text("Show")
+ }
+ }
+ }*/
}
}
}
@@ -277,6 +271,7 @@ fun FeedError(
verticalArrangement = Arrangement.Center,
) {
Text("${stringResource(R.string.error_loading_replies)} $errorMessage")
+ Spacer(modifier = StdVertSpacer)
Button(
modifier = Modifier.align(Alignment.CenterHorizontally),
onClick = onRefresh,
@@ -294,6 +289,7 @@ fun FeedEmpty(onRefresh: () -> Unit) {
verticalArrangement = Arrangement.Center,
) {
Text(stringResource(R.string.feed_is_empty))
+ Spacer(modifier = StdVertSpacer)
OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
index d2610f923..b11e369ba 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
@@ -328,7 +328,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter) :
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
// updates the current list
- currentState.showHidden.value = localFilter.showHiddenKey()
+ if (currentState.showHidden.value != localFilter.showHiddenKey()) {
+ currentState.showHidden.value = localFilter.showHiddenKey()
+ }
currentState.feed.value = notes
} else {
_feedContent.update {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt
index f4c355fdc..e1f3a41d6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt
@@ -45,7 +45,7 @@ fun RefreshingFeedStringFeedView(
enablePullRefresh: Boolean = true,
inner: @Composable (String) -> Unit,
) {
- RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
+ RefresheableBox(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
}
@Composable
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt
index 2d3153de5..9ca328593 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt
@@ -40,7 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -85,51 +85,57 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InlineCarrousel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
-import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost
-import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost
-import com.vitorpamplona.amethyst.ui.elements.DisplayPoW
-import com.vitorpamplona.amethyst.ui.elements.DisplayReward
-import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
-import com.vitorpamplona.amethyst.ui.elements.Reward
-import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
-import com.vitorpamplona.amethyst.ui.note.AudioHeader
-import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader
-import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote
-import com.vitorpamplona.amethyst.ui.note.CreateImageHeader
-import com.vitorpamplona.amethyst.ui.note.DisplayHighlight
-import com.vitorpamplona.amethyst.ui.note.DisplayLocation
-import com.vitorpamplona.amethyst.ui.note.DisplayOts
-import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList
-import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet
-import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
-import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
-import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
-import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
-import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition
-import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack
-import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent
-import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent
-import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent
-import com.vitorpamplona.amethyst.ui.note.RenderPoll
-import com.vitorpamplona.amethyst.ui.note.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.RenderRepost
-import com.vitorpamplona.amethyst.ui.note.RenderTextEvent
-import com.vitorpamplona.amethyst.ui.note.VideoDisplay
+import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits
+import com.vitorpamplona.amethyst.ui.note.elements.ForkInformationRow
+import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
+import com.vitorpamplona.amethyst.ui.note.elements.Reward
+import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.timeAgo
+import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
+import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader
+import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
+import com.vitorpamplona.amethyst.ui.note.types.DisplayHighlight
+import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList
+import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet
+import com.vitorpamplona.amethyst.ui.note.types.EditState
+import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
+import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
+import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
+import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
+import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
+import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThinSendButton
@@ -156,9 +162,11 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.Event
+import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
+import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
@@ -168,6 +176,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.RepostEvent
+import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.toImmutableList
@@ -377,6 +386,8 @@ fun NoteMaster(
.fillMaxWidth()
.padding(top = 10.dp),
) {
+ val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
+
Row(
modifier =
Modifier
@@ -407,6 +418,12 @@ fun NoteMaster(
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
+ if (editState.value is GenericLoadable.Loaded) {
+ (editState.value as? GenericLoadable.Loaded)?.loaded?.let {
+ DisplayEditStatus(it)
+ }
+ }
+
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colorScheme.placeholderText,
@@ -419,12 +436,12 @@ fun NoteMaster(
) {
Icon(
imageVector = Icons.Default.MoreVert,
- null,
+ contentDescription = stringResource(id = R.string.more_options),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
- NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel)
+ NoteDropDownMenu(baseNote, moreActionsExpanded, editState, accountViewModel, nav)
}
}
@@ -532,10 +549,14 @@ fun NoteMaster(
accountViewModel,
nav,
)
+ } else if (noteEvent is FhirResourceEvent) {
+ RenderFhirResource(baseNote, accountViewModel, nav)
} else if (noteEvent is GitRepositoryEvent) {
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
} else if (noteEvent is GitPatchEvent) {
RenderGitPatchEvent(baseNote, false, true, backgroundColor, accountViewModel, nav)
+ } else if (noteEvent is GitIssueEvent) {
+ RenderGitIssueEvent(baseNote, false, true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
@@ -552,6 +573,15 @@ fun NoteMaster(
)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
+ } else if (noteEvent is TextNoteModificationEvent) {
+ RenderTextModificationEvent(
+ note = baseNote,
+ makeItShort = false,
+ canPreview = true,
+ backgroundColor,
+ accountViewModel,
+ nav,
+ )
} else if (noteEvent is PollNoteEvent) {
val canPreview =
note.author == account.userProfile() ||
@@ -577,6 +607,7 @@ fun NoteMaster(
false,
canPreview,
backgroundColor,
+ editState,
accountViewModel,
nav,
)
@@ -591,9 +622,9 @@ fun NoteMaster(
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
- ReactionsRow(note, true, accountViewModel, nav)
+ ReactionsRow(note, true, editState, accountViewModel, nav)
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -633,7 +664,7 @@ private fun RenderClassifiedsReaderForThread(
)
}
} else {
- CreateImageHeader(note, accountViewModel)
+ DefaultImageHeader(note, accountViewModel)
}
Row(
@@ -829,6 +860,11 @@ private fun RenderWikiHeaderForThreadPreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
+ val editState =
+ remember {
+ mutableStateOf(GenericLoadable.Empty())
+ }
+
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null)
@@ -851,6 +887,7 @@ private fun RenderWikiHeaderForThreadPreview() {
false,
true,
backgroundColor,
+ editState,
accountViewModel,
nav,
)
@@ -870,6 +907,7 @@ private fun RenderWikiHeaderForThreadPreview() {
false,
true,
backgroundColor,
+ editState,
accountViewModel,
nav,
)
@@ -934,24 +972,3 @@ private fun RenderWikiHeaderForThread(
}
}
}
-
-@Composable
-fun ForkInformationRow(
- originalVersion: Note,
- modifier: Modifier = Modifier,
- accountViewModel: AccountViewModel,
- nav: (String) -> Unit,
-) {
- val noteState by originalVersion.live().metadata.observeAsState()
- val note = noteState?.note ?: return
- val author = note.author ?: return
- val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
-
- if (route != null) {
- Row(modifier) {
- Text(stringResource(id = R.string.forked_from))
- Spacer(modifier = StdHorzSpacer)
- LoadAndDisplayUser(author, route, nav)
- }
- }
-}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
index 6fc453d92..bc52f5c4a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
@@ -39,7 +39,7 @@ fun RefreshingFeedUserFeedView(
nav: (String) -> Unit,
enablePullRefresh: Boolean = true,
) {
- RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
+ RefresheableBox(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
}
@Composable
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
index 6b6969f78..88ec1d47b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
@@ -932,6 +932,17 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
}
+ fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
+
+ suspend fun findModificationEventsForNote(
+ note: Note,
+ onResult: (List) -> Unit,
+ ) {
+ withContext(Dispatchers.IO) {
+ onResult(LocalCache.findLatestModificationForNote(note))
+ }
+ }
+
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
return LocalCache.checkGetOrCreateChannel(key)
}
@@ -1003,6 +1014,13 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
}
+ fun loadNEmbedIfNeeded(nembed: Event) {
+ val baseNote = LocalCache.getNoteIfExists(nembed.id)
+ if (baseNote?.event == null) {
+ LocalCache.verifyAndConsume(nembed, null)
+ }
+ }
+
suspend fun parseNIP19(
str: String,
onNote: (LoadedBechLink) -> Unit,
@@ -1018,9 +1036,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
is Nip19Bech32.Note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
is Nip19Bech32.NEvent -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
is Nip19Bech32.NEmbed -> {
- if (LocalCache.getNoteIfExists(parsed.event.id) == null) {
- LocalCache.verifyAndConsume(parsed.event, null)
- }
+ loadNEmbedIfNeeded(parsed.event)
LocalCache.checkGetOrCreateNote(parsed.event.id)?.let { note ->
returningNote = note
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt
index 0a586fd87..7286cfc0b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt
@@ -50,10 +50,10 @@ import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -121,18 +121,18 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
-import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.LoadChannel
-import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
+import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
+import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
@@ -640,7 +640,7 @@ fun ChannelHeader(
}
if (showBottomDiviser) {
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -710,8 +710,8 @@ fun ShortChannelHeader(
nav: (String) -> Unit,
showFlag: Boolean,
) {
- val channelState = baseChannel.live.observeAsState()
- val channel = remember(channelState) { channelState.value?.channel } ?: return
+ val channelState by baseChannel.live.observeAsState()
+ val channel = channelState?.channel ?: return
val automaticallyShowProfilePicture =
remember {
@@ -782,8 +782,8 @@ fun LongChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
- val channelState = baseChannel.live.observeAsState()
- val channel = remember(channelState) { channelState.value?.channel } ?: return
+ val channelState by baseChannel.live.observeAsState()
+ val channel = channelState?.channel ?: return
Row(
lineModifier,
@@ -811,6 +811,7 @@ fun LongChannelHeader(
canPreview = false,
tags = tags,
backgroundColor = background,
+ id = baseChannel.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -865,7 +866,7 @@ fun LongChannelHeader(
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(note, remember { Modifier.weight(1f) })
- MoreOptionsButton(note, accountViewModel)
+ MoreOptionsButton(note, null, accountViewModel, nav)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
index 94d4fa306..be3c1f6f7 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
@@ -33,9 +33,9 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -170,7 +170,7 @@ fun ChatroomListTwoPane(
Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) {
ChannelFabColumn(accountViewModel, nav)
}
- Divider(
+ HorizontalDivider(
modifier =
Modifier.fillMaxHeight() // fill the max height
.width(DividerThickness),
@@ -269,7 +269,7 @@ fun ChatroomListScreenOnlyList(
) {
Icon(
imageVector = Icons.Default.MoreVert,
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.more_options),
tint = MaterialTheme.colorScheme.placeholderText,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
index 41df0fd51..048738d96 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
@@ -45,8 +45,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -69,7 +68,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -99,6 +97,8 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject
import com.vitorpamplona.amethyst.ui.note.DisplayUserSetAsSubject
+import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff
+import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
@@ -106,7 +106,6 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
-import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
@@ -115,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
+import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
@@ -442,17 +442,19 @@ fun PrivateMessageEditFieldRow(
},
) {
if (channelScreenModel.nip24) {
- Icon(
- painter = painterResource(id = R.drawable.incognito),
- null,
- modifier = Modifier.padding(top = 2.dp).size(18.dp),
+ IncognitoIconOn(
+ modifier =
+ Modifier
+ .padding(top = 2.dp)
+ .size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else {
- Icon(
- painter = painterResource(id = R.drawable.incognito_off),
- null,
- modifier = Modifier.padding(top = 2.dp).size(18.dp),
+ IncognitoIconOff(
+ modifier =
+ Modifier
+ .padding(top = 2.dp)
+ .size(18.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -535,7 +537,7 @@ fun ThinSendButton(
) {
Icon(
imageVector = Icons.Default.Send,
- null,
+ contentDescription = stringResource(id = R.string.accessibility_send),
modifier = Size20Modifier,
)
}
@@ -601,7 +603,7 @@ fun ChatroomHeader(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -641,7 +643,7 @@ fun GroupChatroomHeader(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -661,11 +663,7 @@ private fun EditRoomSubjectButton(
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { wantsToPost = true },
- shape = ButtonBorder,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- ),
+ contentPadding = ZeroPadding,
) {
Icon(
tint = Color.White,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt
index e42c2fe6c..a5f134cf1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@@ -70,7 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
-import com.vitorpamplona.amethyst.ui.screen.RefresheableView
+import com.vitorpamplona.amethyst.ui.screen.RefresheableBox
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
@@ -197,7 +196,7 @@ private fun DiscoverPages(
}
HorizontalPager(state = pagerState) { page ->
- RefresheableView(tabs[page].viewModel, true) {
+ RefresheableBox(tabs[page].viewModel, true) {
if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) {
SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) {
listState ->
@@ -232,7 +231,7 @@ private fun RenderDiscoverFeed(
viewModel: FeedViewModel,
routeForLastRead: String?,
forceEventKind: Int?,
- listState: ScrollableState,
+ listState: LazyGridState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -251,25 +250,54 @@ private fun RenderDiscoverFeed(
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is FeedState.Loaded -> {
- if (listState is LazyGridState) {
- DiscoverFeedColumnsLoaded(
- state,
- routeForLastRead,
- listState,
- forceEventKind,
- accountViewModel,
- nav,
- )
- } else if (listState is LazyListState) {
- DiscoverFeedLoaded(
- state,
- routeForLastRead,
- listState,
- forceEventKind,
- accountViewModel,
- nav,
- )
- }
+ DiscoverFeedColumnsLoaded(
+ state,
+ routeForLastRead,
+ listState,
+ forceEventKind,
+ accountViewModel,
+ nav,
+ )
+ }
+ is FeedState.Loading -> {
+ LoadingFeed()
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderDiscoverFeed(
+ viewModel: FeedViewModel,
+ routeForLastRead: String?,
+ forceEventKind: Int?,
+ listState: LazyListState,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
+
+ Crossfade(
+ targetState = feedState,
+ animationSpec = tween(durationMillis = 100),
+ label = "RenderDiscoverFeed",
+ ) { state ->
+ when (state) {
+ is FeedState.Empty -> {
+ FeedEmpty { viewModel.invalidateData() }
+ }
+ is FeedState.FeedError -> {
+ FeedError(state.errorMessage) { viewModel.invalidateData() }
+ }
+ is FeedState.Loaded -> {
+ DiscoverFeedLoaded(
+ state,
+ routeForLastRead,
+ listState,
+ forceEventKind,
+ accountViewModel,
+ nav,
+ )
}
is FeedState.Loading -> {
LoadingFeed()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt
index 33b0dc61c..725154bbd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt
@@ -27,36 +27,30 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
-import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
+import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.StdPadding
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
@Composable
fun GeoHashScreen(
@@ -163,7 +157,7 @@ fun GeoHashHeader(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
@@ -174,27 +168,13 @@ fun DislayGeoTagHeader(
geohash: String,
modifier: Modifier,
) {
- val context = LocalContext.current
-
- var cityName by remember(geohash) { mutableStateOf(geohash) }
-
- LaunchedEffect(key1 = geohash) {
- launch(Dispatchers.IO) {
- val newCityName =
- ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank {
- null
- }
- if (newCityName != null && newCityName != cityName) {
- cityName = "$newCityName ($geohash)"
- }
- }
+ LoadCityName(geohashStr = geohash) { cityName ->
+ Text(
+ cityName,
+ fontWeight = FontWeight.Bold,
+ modifier = modifier,
+ )
}
-
- Text(
- cityName,
- fontWeight = FontWeight.Bold,
- modifier = modifier,
- )
}
@Composable
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt
index de08249ff..2132d88ae 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -160,7 +160,7 @@ fun HashtagHeader(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
index 1381ee22b..a2ff9649a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
@@ -34,7 +34,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
-import androidx.compose.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
@@ -67,11 +67,11 @@ import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.ui.elements.AddButton
+import com.vitorpamplona.amethyst.ui.note.elements.AddButton
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenWordsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel
-import com.vitorpamplona.amethyst.ui.screen.RefresheableView
+import com.vitorpamplona.amethyst.ui.screen.RefresheableBox
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.StringFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
@@ -177,7 +177,7 @@ fun HiddenUsersScreen(
edgePadding = 8.dp,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight,
- divider = { Divider(thickness = DividerThickness) },
+ divider = { HorizontalDivider(thickness = DividerThickness) },
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -216,7 +216,7 @@ private fun HiddenWordsFeed(
hiddenWordsViewModel: NostrHiddenWordsFeedViewModel,
accountViewModel: AccountViewModel,
) {
- RefresheableView(hiddenWordsViewModel, false) {
+ RefresheableBox(hiddenWordsViewModel, false) {
StringFeedView(
hiddenWordsViewModel,
post = { AddMuteWordTextField(accountViewModel) },
@@ -313,7 +313,7 @@ fun MutedWordHeader(
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
index 7773764f6..24c5a7ede 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
@@ -22,11 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@@ -39,9 +43,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -54,10 +60,14 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
-import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
+import com.vitorpamplona.amethyst.ui.screen.RefresheableBox
+import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
+import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
+import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -181,7 +191,7 @@ private fun HomePages(
}
HorizontalPager(state = pagerState, userScrollEnabled = false) { page ->
- RefresheableFeedView(
+ HomeFeeds(
viewModel = tabs[page].viewModel,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey,
@@ -191,6 +201,51 @@ private fun HomePages(
}
}
+@Composable
+fun HomeFeeds(
+ viewModel: FeedViewModel,
+ routeForLastRead: String?,
+ enablePullRefresh: Boolean = true,
+ scrollStateKey: String? = null,
+ accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
+) {
+ RefresheableBox(viewModel, enablePullRefresh) {
+ SaveableFeedState(viewModel, scrollStateKey) { listState ->
+ RenderFeedState(
+ viewModel = viewModel,
+ accountViewModel = accountViewModel,
+ listState = listState,
+ nav = nav,
+ routeForLastRead = routeForLastRead,
+ onEmpty = { HomeFeedEmpty { viewModel.invalidateData() } },
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun HomeFeedEmptyPreview() {
+ ThemeComparisonRow(
+ onDark = { HomeFeedEmpty {} },
+ onLight = { HomeFeedEmpty {} },
+ )
+}
+
+@Composable
+fun HomeFeedEmpty(onRefresh: () -> Unit) {
+ Column(
+ Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(stringResource(R.string.feed_is_empty))
+ Spacer(modifier = StdVertSpacer)
+ OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) }
+ }
+}
+
@Composable
fun CheckIfUrlIsOnline(
url: String,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt
index 99269225e..2ba1c7532 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt
@@ -27,7 +27,7 @@ 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.material3.Divider
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
-import com.vitorpamplona.amethyst.ui.screen.RefresheableCardView
+import com.vitorpamplona.amethyst.ui.screen.RefreshableCardView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -121,7 +121,7 @@ fun NotificationScreen(
model = userReactionsStatsModel,
)
- RefresheableCardView(
+ RefreshableCardView(
viewModel = notifFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
@@ -230,7 +230,7 @@ fun SummaryBar(model: UserReactionsViewModel) {
}
}
- Divider(
+ HorizontalDivider(
thickness = DividerThickness,
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
index 094682cbe..f1b118ed1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
@@ -57,9 +57,9 @@ import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -476,7 +476,7 @@ private fun RenderScreen(
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
modifier = tabRowModifier,
- divider = { Divider(thickness = DividerThickness) },
+ divider = { HorizontalDivider(thickness = DividerThickness) },
) {
CreateAndRenderTabs(baseUser, pagerState)
}
@@ -819,7 +819,7 @@ private fun ProfileHeader(
DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav)
- Divider(modifier = Modifier.padding(top = 6.dp))
+ HorizontalDivider(modifier = Modifier.padding(top = 6.dp))
}
}
@@ -1103,6 +1103,7 @@ private fun DrawAdditionalInfo(
canPreview = false,
tags = EmptyTagList,
backgroundColor = background,
+ id = it,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -1870,7 +1871,7 @@ fun UserProfileDropDownMenu(
)
if (accountViewModel.userProfile() != user) {
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.isHidden(user)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.unblock_user)) },
@@ -1888,7 +1889,7 @@ fun UserProfileDropDownMenu(
},
)
}
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.report_spam_scam)) },
onClick = {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt
index 69c921b61..4186d0720 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt
@@ -33,8 +33,8 @@ import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Report
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -61,6 +61,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.quartz.events.ReportEvent
import kotlinx.collections.immutable.toImmutableList
@@ -126,7 +127,7 @@ fun ReportNoteDialog(
)
SpacerH16()
- Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp)
+ HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness)
SpacerH16()
SectionHeader(text = stringResource(R.string.report_dialog_report_btn))
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
index 57cc6bbca..437c0e12b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
@@ -36,8 +36,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -446,7 +446,7 @@ fun HashtagLine(
}
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
@@ -481,7 +481,7 @@ fun UserLine(
}
}
- Divider(
+ HorizontalDivider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
index 24ff01a66..59eb824e3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
@@ -57,36 +57,38 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.BoostReaction
-import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
-import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
-import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.WatchForReports
import com.vitorpamplona.amethyst.ui.note.ZapReaction
+import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
+import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
-import com.vitorpamplona.amethyst.ui.screen.RefresheableView
+import com.vitorpamplona.amethyst.ui.screen.RefresheableBox
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
@@ -214,7 +216,7 @@ private fun LoadedState(
WatchScrollToTop(videoFeedView, pagerState)
- RefresheableView(viewModel = videoFeedView) {
+ RefresheableBox(viewModel = videoFeedView) {
SlidingCarousel(
state.feed,
pagerState,
@@ -363,12 +365,12 @@ private fun RenderAuthorInformation(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
- VideoUserOptionAction(note, accountViewModel)
+ VideoUserOptionAction(note, accountViewModel, nav)
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
- remember { note.author!! },
- remember { Modifier.weight(1f) },
+ note.author!!,
+ Modifier.weight(1f),
accountViewModel,
nav = nav,
)
@@ -387,6 +389,7 @@ private fun RenderAuthorInformation(
private fun VideoUserOptionAction(
note: Note,
accountViewModel: AccountViewModel,
+ nav: (String) -> Unit,
) {
val popupExpanded = remember { mutableStateOf(false) }
val enablePopup = remember { { popupExpanded.value = true } }
@@ -397,7 +400,7 @@ private fun VideoUserOptionAction(
) {
Icon(
imageVector = Icons.Default.MoreVert,
- null,
+ contentDescription = stringResource(id = R.string.more_options),
modifier = remember { Modifier.size(20.dp) },
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -405,7 +408,9 @@ private fun VideoUserOptionAction(
NoteDropDownMenu(
note,
popupExpanded,
+ null,
accountViewModel,
+ nav,
)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt
index 0d10f938d..585516526 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt
@@ -56,7 +56,7 @@ val EditFieldBorder = RoundedCornerShape(25.dp)
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
-val StdButtonSizeModifier = Modifier.size(20.dp)
+val StdButtonSizeModifier = Modifier.size(19.dp)
val HalfVertSpacer = Modifier.height(2.dp)
@@ -146,9 +146,9 @@ val Height4dpModifier = Modifier.height(4.dp)
val AccountPictureModifier = Modifier.size(55.dp).clip(shape = CircleShape)
val HeaderPictureModifier = Modifier.size(34.dp).clip(shape = CircleShape)
-val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp)
-val ShowMoreRelaysButtonIconModifier = Modifier.size(15.dp)
-val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp)
+val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(15.dp)
+val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp)
+val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(17.dp)
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)
@@ -193,6 +193,10 @@ val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizont
val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth()
+val imageHeaderBannerSize = Modifier.fillMaxWidth().height(150.dp)
+
+val authorNotePictureForImageHeader = Modifier.size(75.dp).padding(10.dp)
+
val normalNoteModifier =
Modifier.fillMaxWidth()
.padding(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt
index 2fcdc92b9..4bfebcc01 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt
@@ -167,14 +167,14 @@ val LightReplyBorderModifier =
val DarkInnerPostBorderModifier =
Modifier
- .padding(top = 5.dp)
+ .padding(vertical = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(1.dp, DarkSubtleBorder, QuoteBorder)
val LightInnerPostBorderModifier =
Modifier
- .padding(top = 5.dp)
+ .padding(vertical = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder)
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 6242ab259..0c2a3d4a0 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -41,6 +41,8 @@
مشاهدة العد
تعزيز
معزز
+ تم تعديلها
+ الأصلية
إقتباس
مبلغ جديد في Sats
إضافة
@@ -143,6 +145,7 @@
"اقبل شروط الاستخدام "
شروط الاستخدام
يجب قبول شروط الإستخدام
+ كلمة السر مطلوبة
المفتاح مطلوب
يجب ادخال اسم
تسجيل دخول
@@ -193,8 +196,19 @@
ضع علامة على جميع الرسائل/الاشعارات الجديدة كمقروء
ضع علامة على جميع الرسائل/الاشعارات كمقروء
قم بعمل نسخة احتياطية للمفاتيح
+ ## النصائح الرئيسية للنسخ الاحتياطي والسلامة
+ \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك.
+ \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به.
+ \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك.
+ \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور.
+
+ للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك.
+ \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص.
+
+ فشل في تشفير مفتاحك الخاص
مفتاحك السري (nsec) قد تم نسخه في الحافظة
إنسخ مفتاحي السري
+ تشفير و نسخ المفتاح الخاص
فشلت المصادقة
خطأ
"تم إنشاؤه بواسطة %1$s"
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 5cd064b5e..1fbfee90c 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -6,6 +6,7 @@
Profilový obrázek
Skenovat QR kód
Zobrazit přesto
+ Tento příspěvek byl skryt, protože zmiňuje vaše skryté uživatele nebo slova
Příspěvek byl označen jako nevhodný uživatelem
Příspěvek nenalezen
Obrázek kanálu
@@ -16,6 +17,7 @@
Spam
Zneužívání identity
Nezákonné jednání
+ Jiný
Neznámý
Ikona předávání
Neznámý autor
@@ -23,6 +25,9 @@
Kopírovat ID autora
Kopírovat ID poznámky
Vysílání
+ Časové razítko
+ Časové razítko: Čeká na potvrzení
+ OTS: Čeká
Požadovat smazání
Blockovat / Nahlásit
@@ -43,8 +48,12 @@
Počet zobrazení
Zvýšení
zvýšeno
+ upraveno
+ úprava #%1$s
+ originál
Citovat
Rozštěpení
+ Navrhnout úpravu
Nová částka v sats
Přidat
"odpovídá na "
@@ -661,4 +670,21 @@
Git repositář: %1$s
Internet:
Klon:
+ OTS: %1$s
+ Důkaz časového razítka
+ Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase.
+ Upravit příspěvek
+ Návrh na vylepšení vašeho příspěvku
+ Souhrn změn
+ Rychlé opravy…
+ Přijmout návrhy
+ Stáhnout
+ Text písně zapnuto
+ Text písně vypnuto
+ Zapečetěná zpráva vypnuta. Klikněte pro zapnutí zapečetěné zprávy
+ Zapečetěná zpráva zapnuta. Klikněte pro vypnutí zapečetěné zprávy
+ Odeslat
+ Přehrát uživatelské jméno jako audio
+ Skenovat QR kód
+ Přejít na poskytovatele peněženky třetí strany Alby
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 3f5c2d99d..cd528fecc 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -6,6 +6,7 @@
Dein Profilbild
QR Code scannen
Trotzdem anzeigen
+ Dieser Beitrag wurde ausgeblendet, weil er Ihre ausgeblendeten Benutzer oder Wörter erwähnt
Der Beitrag wurde als unangemessen gekennzeichnet von
Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden
Kanalbild
@@ -16,6 +17,7 @@
Spam
Vortäuschung
Illegales Verhalten
+ Andere
Unbekannt
Relay-Symbol
Unbekannter Autor
@@ -23,6 +25,9 @@
Autor-ID kopieren
Notiz-ID kopieren
Übertragungen
+ Zeitstempel es
+ Zeitstempel: Ausstehende Bestätigungen
+ OTS: Ausstehend
Löschung beantragen
Blockieren / Melden
@@ -43,8 +48,12 @@
Aufrufe
Boost
boosted
+ bearbeitet
+ Bearbeitung #%1$s
+ Original
Zitat
Fork
+ Eine Bearbeitung vorschlagen
Neuer Betrag in Sats
Hinzufügen
"Antworten auf "
@@ -666,4 +675,21 @@ anz der Bedingungen ist erforderlich
Git Repository: %1$s
Internet:
Klonen:
+ OTS: %1$s
+ Zeitstempel Beweis
+ Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt.
+ Beitrag bearbeiten
+ Vorschlag zur Verbesserung Ihres Beitrags
+ Zusammenfassung der Änderungen
+ Schnelle Korrekturen…
+ Den Vorschlag annehmen
+ Herunterladen
+ Liedtext an
+ Liedtext aus
+ Versiegelte Nachricht aus. Klicken Sie, um die versiegelte Nachricht einzuschalten
+ Versiegelte Nachricht an. Klicken Sie, um die versiegelte Nachricht auszuschalten
+ Senden
+ Benutzernamen als Audio abspielen
+ QR-Code scannen
+ Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index 62a2a8e82..67effe4b5 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -6,6 +6,7 @@
Tu imagen de perfil
Escanear el QR
Mostrar de todos modos
+ Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
Post marcado como inapropiado por
El evento se está cargando o no se puede encontrar en la lista de relés
Imagen del canal
@@ -47,8 +48,12 @@
Total de visualizaciones
Impulsar
Impulsada
+ editada
+ edición #%1$s
+ original
Citar
Bifurcar
+ Proponer una edición
Nueva cantidad en sats
Añadir
"respondiendo a "
@@ -670,4 +675,18 @@
OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
+ Editar publicación
+ Propuesta para mejorar una publicación
+ Resumen de cambios
+ Correcciones rápidas…
+ Aceptar la sugerencia
+ Descargar
+ Letra activada
+ Letra desactivada
+ Mensaje sellado desactivado. Haz clic para activar el mensaje sellado.
+ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado.
+ Enviar
+ Reproducir nombre de usuario como audio
+ Escanear código QR
+ Ir al proveedor externo de monederos Alby
diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml
index 2852fb8e8..437043ab6 100644
--- a/app/src/main/res/values-es-rMX/strings.xml
+++ b/app/src/main/res/values-es-rMX/strings.xml
@@ -6,6 +6,7 @@
Tu imagen de perfil
Escanear código QR
Mostrar de todos modos
+ Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
La publicación fue reportada por
El evento se está cargando o no se puede encontrar en la lista de relés
Imagen del canal
@@ -47,8 +48,12 @@
Total de visualizaciones
Impulsar
Impulsada
+ editada
+ edición #%1$s
+ original
Cita
Bifurcar
+ Proponer una edición
Nueva cantidad en sats
Agregar
"respondiendo a "
@@ -670,4 +675,18 @@
OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
+ Editar publicación
+ Propuesta para mejorar una publicación
+ Resumen de cambios
+ Correcciones rápidas…
+ Aceptar la sugerencia
+ Descargar
+ Letra activada
+ Letra desactivada
+ Mensaje sellado desactivado. Haz clic para activar el mensaje sellado.
+ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado.
+ Enviar
+ Reproducir nombre de usuario como audio
+ Escanear código QR
+ Ir al proveedor externo de billeteras Alby
diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml
index 09a021900..d7f0391f6 100644
--- a/app/src/main/res/values-es-rUS/strings.xml
+++ b/app/src/main/res/values-es-rUS/strings.xml
@@ -6,6 +6,7 @@
Tu imagen de perfil
Escanear código QR
Mostrar de todos modos
+ Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
La publicación fue reportada por
El evento se está cargando o no se puede encontrar en la lista de relés
Imagen del canal
@@ -47,8 +48,12 @@
Total vistas
Impulsar
Impulsada
+ editada
+ edición #%1$s
+ original
Cita
Bifurcar
+ Proponer una edición
Nueva cantidad en sats
Agregar
"respondiendo a "
@@ -670,4 +675,18 @@
OTS: %1$s
Prueba de marca de tiempo
Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.
+ Editar publicación
+ Propuesta para mejorar una publicación
+ Resumen de cambios
+ Correcciones rápidas…
+ Aceptar la sugerencia
+ Descargar
+ Letra activada
+ Letra desactivada
+ Mensaje sellado desactivado. Haz clic para activar el mensaje sellado.
+ Mensaje sellado activado. Haz clic para desactivar el mensaje sellado.
+ Enviar
+ Reproducir nombre de usuario como audio
+ Escanear código QR
+ Ir al proveedor externo de billeteras Alby
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 266c0422d..5e72a863f 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -6,6 +6,7 @@
Votre Photo de Profil
Scanner le QR code
Montrer quand même
+ Ce message a été masqué car il mentionne vos utilisateurs ou mots cachés
Post signalé inapproprié par
Post non trouvé
Image du canal
@@ -47,8 +48,12 @@
Nombre de vues
Boost
boosté
+ modifié
+ éditer #%1$s
+ original
Citation
Fork
+ Proposer une Modification
Nouveau montant en Sats
Ajouter
"répondre à "
@@ -418,8 +423,8 @@
DÉCONNECTÉ
TERMINÉ
PLANIFIÉ
- Livestream est déconnecté
- Livestream terminé
+ La diffusion en direct est déconnectée
+ La diffusion en direct est terminée
Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ?
Tags suivis
Relais
@@ -670,4 +675,9 @@
OTS: %1$s
Preuve d\'horodatage
Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure.
+ Modifier le Message
+ Proposition pour améliorer votre publication
+ Récapitulatif des modifications
+ Corrections rapides ...
+ Accepter la Suggestion
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index d4715f21a..ce13985fd 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -6,6 +6,7 @@
Profilkép
QR kód beolvasása
Mutasd
+ Ez a bejegyzés el lett rejtve, mert említ rejtett felhasználóidat vagy szavaidat
A bejegyzést nem megfelelőként jelölte meg
Bejegyzés nem található
Csatorna Kép
@@ -16,6 +17,7 @@
Spam
Megszemélyesítés
Illegális viselkedés
+ Egyéb
Ismeretlen
Csomópont Ikon
Ismeretlen Szerző
@@ -23,6 +25,9 @@
A felhasználó PubKulcs másolása
A bejegyzés azonosítójának másolása
Közvetít
+ Időbélyegezni
+ Időbélyeg: függőben lévő megerősítések
+ OTS: Függőben
Törlés Kérése
Tiltás és Jelentés
@@ -43,7 +48,12 @@
Megtekintések száma
Megosztás
Megosztva
+ szerkesztve
+ szerkesztés #%1$s
+ eredeti
Idézet
+ Fork
+ Szerkesztési javaslat
Új összeg sats-ban
Hozzáad
"válasz erre "
@@ -647,4 +657,35 @@
Az npub megjelenítése QR-kódként
Érvénytelen cím
Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s
+ Zap a fejlesztőknek!
+ Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít!
+ Adományozz Most
+ hozta neked:
+ Ezt a verziót hozta neked:
+ %1$s verzió
+ Köszönöm!
+ Max Limit
+ Korlátozott írások
+ Fork-olva innen
+ FORK
+ Git-tár: %1$s
+ Web:
+ Klónoz:
+ OTS: %1$s
+ Időbélyeg igazolás
+ Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban a Bitcoin blokkláncába bélyegezték.
+ Bejegyzés szerkesztése
+ Javaslat egy bejegyzés javítására
+ Változások összefoglalása
+ Gyors javítások…
+ Fogadd el a Javaslatot
+ Letöltés
+ Feliratozás bekapcsolva
+ Feliratozás kikapcsolva
+ Lezárt üzenetek kikapcsolva. Kattints a lezárt üzenetek bekapcsolásához
+ Lezárt üzenetek bekapcsolva. Kattints a lezárt üzenetek kikapcsolásához
+ Küldés
+ Felhasználónév lejátszása hangként
+ QR-kód beolvasása
+ Keresd fel a harmadik féltől származó pénztárcaszolgáltatót, az Alby-t
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index ea7fb69b2..445f7b117 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -16,6 +16,7 @@
Spam
Imitatie
Illegaal gedrag
+ Overige
Onbekend
Relay icoon
Onbekende auteur
@@ -23,6 +24,9 @@
Kopieer auteur ID
Kopieer note ID
Verzenden
+ Tijdstempel
+ Tijdstempel: in behandeling zijnde bevestigingen
+ OTS: In behandeling
Verwijdering aanvragen
Blokkeren / Rapporteren
@@ -43,7 +47,10 @@
Aantal keer bekeken
Boost
boosted
+ gewijzigd
+ origineel
Citaat
+ Fork
Nieuw bedrag in sats
Toevoegen
"reageert op "
@@ -139,12 +146,15 @@
Wissen
App logo
nsec.. of npub..
+ wachtwoord om de sleutel te openen
Toon wachtwoord
Verberg wachtwoord
Ongeldige sleutel
+ Ongeldige sleutel: %1$s
"Ik accepteer de "
gebruiksvoorwaarden
Accepteren van de voorwaarden is vereist
+ Wachtwoord is vereist
Sleutel is vereist
Een naam is verplicht
Inloggen
@@ -195,8 +205,19 @@
Nieuwe markeren als gelezen
Alles markeren als gelezen
Back-up sleutels
+ ## Sleutel back-up en veiligheidstips
+ \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit.
+ \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt.
+ \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen.
+ \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken.
+
+ Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord.
+ \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen.
+
+ Versleutelen van uw privésleutel is mislukt
Privésleutel (nsec) gekopieerd naar klembord
Kopieer mijn privésleutel
+ Versleutel en kopieer mijn geheime sleutel
Authenticatie mislukt
Biometrie kon de eigenaar van deze telefoon niet verifiëren
Biometrie kon de eigenaar van deze telefoon niet verifiëren. Fout: %1$s
@@ -634,4 +655,21 @@
Toon npub als een QR-code
Ongeldig adres
Amethist ontving een URI om te openen, maar die uri was ongeldig: %1$s
+ Zap de devs!
+ Jouw donatie helpt ons om een verschil te maken. Elke sat telt!
+ Nu doneren
+ werd je aangeboden door:
+ Deze versie werd je aangeboden door:
+ Versie %1$s
+ Bedankt!
+ Max. limiet
+ Beperkte schrijfrechten
+ FORK
+ Git Repository: %1$s
+ Web:
+ Kopiëren:
+ OTS: %1$s
+ Bewijs van tijdstempel
+ Er is bewijs dat dit bericht enige tijd voor %1$sis getekend. Het bewijs is op dat tijdstip en op dat moment getypeerd in de Bitcoin-blockchain.
+ Bericht wijzigen
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index d62aaa9e2..d7484c17d 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -6,6 +6,7 @@
Sua Foto de Perfil
Ler QR
Mostrar de qualquer maneira
+ Esta postagem foi ocultada porque menciona seus usuários ou palavras ocultas
A postagem foi sinalizada como imprópria por
postagem não encontrada
Imagem do Canal
@@ -16,6 +17,7 @@
Spam
Representação
Comportamento ilegal
+ Outro
Desconhecido
Ícone do relay
Autor desconhecido
@@ -23,6 +25,9 @@
Copiar PubKey do usuário
Copiar ID da Nota
Transmitir
+ Carimbá-lo com data/hora
+ Carimbo de data/hora: Confirmações pendentes
+ OTS: Pendente
Pedir para excluir
Bloquear / Denunciar
@@ -43,8 +48,12 @@
Contagem de visualizações
Impulsionar
impulsionado
+ editado
+ edição #%1$s
+ original
Citar
Garfo
+ Propor uma Edição
Novo Valor em Sats
Adicionar
"respondendo para "
@@ -661,4 +670,21 @@
Repositório Git: %1$s
Site:
Clonar:
+ OTS: %1$s
+ Prova de Carimbo de data/hora
+ Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora.
+ Editar postagem
+ Proposta para melhorar sua postagem
+ Resumo das alterações
+ Correções rápidas…
+ Aceitar a Sugestão
+ Baixar
+ Letras ligadas
+ Letras desligadas
+ Mensagem selada desligada. Clique para ligar a mensagem selada
+ Mensagem selada ligada. Clique para desligar a mensagem selada
+ Enviar
+ Reproduzir nome de usuário como áudio
+ Escanear código QR
+ Navegar para o provedor de carteira de terceiros Alby
diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml
index e099d4ffb..0378413f2 100644
--- a/app/src/main/res/values-sv-rSE/strings.xml
+++ b/app/src/main/res/values-sv-rSE/strings.xml
@@ -6,6 +6,7 @@
Din profilbild
Scanna QR
Visa ändå
+ Detta inlägg doldes eftersom det nämner dina dolda användare eller ord
Inlägget flaggades som olämpligt av
Inlägg hittades inte
Kanal bild
@@ -16,6 +17,7 @@
Spam
Imitation
Olagligt beteende
+ Annat
Okänd
Relä ikon
Okänd användare
@@ -23,6 +25,9 @@
Kopiera användarens ID
Kopiera anteckningens ID
Sänd ut
+ Tidsstämpla det
+ Tidsstämpel: Väntar på bekräftelser
+ OTS: Väntar
Begär radering
Blockera / Rapportera
@@ -43,8 +48,12 @@
Antal visningar
Boosta
boostad
+ redigerat
+ redigering #%1$s
+ original
Citera
Förgrening
+ Föreslå en redigering
Nytt belopp i Sats
Lägg till
"Svarar till "
@@ -660,4 +669,21 @@
Git Repository: %1$s
Webbplats:
Klona:
+ OTS: %1$s
+ Tidsstämpel Bevis
+ Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden.
+ Redigera inlägg
+ Förslag till att förbättra ditt inlägg
+ Sammanfattning av ändringar
+ Snabba fixar…
+ Acceptera förslaget
+ Ladda ner
+ Undertexter på
+ Undertexter av
+ Förseglat meddelande av. Klicka för att slå på förseglat meddelande
+ Förseglat meddelande på. Klicka för att stänga av förseglat meddelande
+ Skicka
+ Spela upp användarnamn som ljud
+ Skanna QR-kod
+ Navigera till tredjeparts plånboksleverantören Alby
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 55de47ddb..16eb4016f 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -3,8 +3,10 @@
สแกน Qr Code
โชว์ QR
รูปโปรไฟล์
+ รูปโปรไฟล์
แสกน QR
แสดง
+ โน๊ตนี้ถูกซ่อนเนื่องจากมีเนื้อหา หรือผู้ใช้ ที่ท่านไม่อยากเห็น
โพสต์ถูกรายงานว่าไม่เหมาะสมโดย
ไม่พบโพสต์นี้
รูปของ channel
@@ -15,6 +17,7 @@
สแปม
เลียนแบบ
พฤติกรรมที่ผิดกฎหมาย
+ อื่นๆ
ไม่ทราบ
รีเลย์ไอคอน
ไม่ทราบผู้เขียน
@@ -22,6 +25,8 @@
คัดลอก ID ผู้เขียน
คัดลอก ID โน้ต
เพยแพร่
+ ประทับเวลา
+ กำลังรอยืนยันการประทับเวลา
ส่งคำขอให้ลบ
บล๊อก / รายงาน
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f015ba35f..c1aa102b9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,6 +7,7 @@
Your Profile Picture
Scan QR
Show Anyway
+ This post was hidden because it mentions your hidden users or words
Post was muted or reported by
Event is loading or can\'t be found in your relay list
Channel Image
@@ -50,8 +51,12 @@
View count
Boost
boosted
+ edited
+ edit #%1$s
+ original
Quote
Fork
+ Propose an Edit
New Amount in Sats
Add
"replying to "
@@ -792,4 +797,21 @@
Timestamp Proof
There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time.
+
+ Edit Post
+ Proposal to improve a post
+ Summary of changes
+ Quick fixes…
+
+ Accept the Suggestion
+
+ Download
+ Lyrics on
+ Lyrics off
+ Sealed message off. Click to turn on sealed message
+ Sealed message on. Click to turn off sealed message
+ Send
+ Play username as audio
+ Scan QR code
+ Navigate to the third-party wallet provider Alby
diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
index 41f4eb250..cff6738d5 100644
--- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
+++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
@@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
-import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -60,6 +60,7 @@ import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
@@ -73,6 +74,7 @@ fun TranslatableRichTextViewer(
modifier: Modifier = Modifier,
tags: ImmutableListOfLists,
backgroundColor: MutableState,
+ id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -97,6 +99,7 @@ fun TranslatableRichTextViewer(
modifier,
tags,
backgroundColor,
+ id,
accountViewModel,
nav,
)
@@ -111,6 +114,7 @@ private fun RenderText(
modifier: Modifier,
tags: ImmutableListOfLists,
backgroundColor: MutableState,
+ id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -129,6 +133,7 @@ private fun RenderText(
modifier,
tags,
backgroundColor,
+ id,
accountViewModel,
nav,
)
@@ -243,7 +248,7 @@ private fun TranslationMessage(
}
},
)
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
if (accountViewModel.account.preferenceBetween(source, target) == source) {
@@ -300,7 +305,7 @@ private fun TranslationMessage(
}
},
)
- Divider()
+ HorizontalDivider(thickness = DividerThickness)
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
diff --git a/benchmark/build.gradle b/benchmark/build.gradle
index 61ab23cfb..be097e2d7 100644
--- a/benchmark/build.gradle
+++ b/benchmark/build.gradle
@@ -1,7 +1,7 @@
plugins {
- id 'com.android.library'
- id 'androidx.benchmark'
- id 'org.jetbrains.kotlin.android'
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.jetbrainsKotlinAndroid)
+ alias(libs.plugins.androidBenchmark)
}
android {
@@ -49,10 +49,10 @@ android {
}
dependencies {
- androidTestImplementation 'androidx.test:runner:1.5.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.3'
+ androidTestImplementation libs.androidx.runner
+ androidTestImplementation libs.androidx.junit
+ androidTestImplementation libs.junit
+ androidTestImplementation libs.androidx.benchmark.junit4
androidTestImplementation project(path: ':quartz')
androidTestImplementation project(path: ':commons')
diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt
index c3408f582..5bf80dafb 100644
--- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt
+++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt
@@ -49,6 +49,7 @@ class RichTextParserBenchmark {
RichTextParser().parseMediaUrl(
"https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk",
EmptyTagList,
+ null,
),
)
}
diff --git a/build.gradle b/build.gradle
index e72a837d7..121417ec7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,32 +1,13 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-buildscript {
- ext {
- fragment_version = "1.6.2"
- lifecycle_version = '2.7.0'
- compose_ui_version = '1.6.1'
- nav_version = '2.7.7'
- room_version = "2.4.3"
- accompanist_version = '0.34.0'
- coil_version = '2.6.0'
- vico_version = '1.14.0'
- media3_version = '1.2.1'
- core_ktx_version = '1.12.0'
- material3_version = '1.2.0'
- }
- dependencies {
- classpath 'com.google.gms:google-services:4.4.1'
- }
-}
-
plugins {
- id 'com.android.application' version '8.2.2' apply false
- id 'com.android.library' version '8.2.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
- id 'org.jetbrains.kotlin.jvm' version '1.9.22' apply false
- id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false
- id 'androidx.benchmark' version '1.2.3' apply false
- id 'com.diffplug.spotless' version '6.25.0' apply false
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.jetbrainsKotlinAndroid) apply false
+ alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.jetbrainsKotlinJvm) apply false
+ alias(libs.plugins.androidBenchmark) apply false
+ alias(libs.plugins.diffplugSpotless) apply false
+ alias(libs.plugins.googleServices) apply false
}
subprojects {
diff --git a/commons/build.gradle b/commons/build.gradle
index a30b159dd..6adfe0abd 100644
--- a/commons/build.gradle
+++ b/commons/build.gradle
@@ -1,6 +1,6 @@
plugins {
- id 'com.android.library'
- id 'org.jetbrains.kotlin.android'
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.jetbrainsKotlinAndroid)
}
android {
@@ -38,12 +38,13 @@ dependencies {
implementation project(path: ':quartz')
// Import @Immutable and @Stable
- implementation "androidx.compose.ui:ui:$compose_ui_version"
+ implementation platform(libs.androidx.compose.bom)
+ implementation libs.androidx.ui
// immutable collections to avoid recomposition
- api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7')
+ api libs.kotlinx.collections.immutable
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.junit
+ androidTestImplementation libs.androidx.espresso.core
}
\ No newline at end of file
diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt
index bb205711b..dcd6b6eb4 100644
--- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt
+++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt
@@ -45,6 +45,7 @@ class RichTextParser() {
fun parseMediaUrl(
fullUrl: String,
eventTags: ImmutableListOfLists,
+ description: String?,
): MediaUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
@@ -53,7 +54,7 @@ class RichTextParser() {
MediaUrlImage(
url = fullUrl,
- description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
+ description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
@@ -64,7 +65,7 @@ class RichTextParser() {
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
MediaUrlVideo(
url = fullUrl,
- description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
+ description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
@@ -106,7 +107,7 @@ class RichTextParser() {
val urlSet = parseValidUrls(content)
val imagesForPager =
- urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
+ urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 000000000..6ac65c0e2
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,124 @@
+[versions]
+accompanistAdaptive = "0.34.0"
+activityCompose = "1.8.2"
+agp = "8.3.0"
+androidKotlinGeohash = "1.0"
+androidLifecycle = "2.7.0"
+androidxJunit = "1.2.0-alpha03"
+appcompat = "1.6.1"
+audiowaveform = "1.1.1"
+benchmark = "1.2.3"
+benchmarkJunit4 = "1.2.3"
+biometricKtx = "1.2.0-alpha05"
+blurhash = "1.0.0"
+coil = "2.6.0"
+composeBom = "2024.02.01"
+coreKtx = "1.12.0"
+espressoCore = "3.5.1"
+firebaseBom = "32.7.3"
+fragmentKtx = "1.6.2"
+gms = "4.4.1"
+jacksonModuleKotlin = "2.16.1"
+jna = "5.14.0"
+jsoup = "1.17.2"
+junit = "4.13.2"
+kotlin = "1.9.22"
+kotlinxCollectionsImmutable = "0.3.7"
+languageId = "17.0.5"
+lazysodiumAndroid = "5.1.0"
+lightcompressor = "1.3.2"
+markdown = "48702a8ced"
+media3 = "1.2.1"
+mockk = "1.13.10"
+navigationCompose = "2.7.7"
+okhttp = "5.0.0-alpha.12"
+runner = "1.5.2"
+secp256k1KmpJniAndroid = "0.15.0"
+securityCryptoKtx = "1.1.0-alpha06"
+spotless = "6.25.0"
+translate = "17.0.2"
+unifiedpush = "2.2.0"
+urlDetector = "0.1.23"
+vico-charts = "1.14.0"
+zelory = "3.0.1"
+zoomable = "1.6.0"
+zxing = "3.5.3"
+zxingAndroidEmbedded = "4.3.0"
+
+[libraries]
+abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" }
+accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" }
+accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmarkJunit4" }
+androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
+androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" }
+androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidLifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidLifecycle" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidLifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidLifecycle" }
+androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" }
+androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
+androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
+androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
+androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
+androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
+androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
+androidx-runtime-runtime = { group = "androidx.compose.runtime", name = "runtime" }
+androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+audiowaveform = { group = "com.github.lincollincol", name = "compose-audiowaveform", version.ref = "audiowaveform" }
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
+coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
+drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
+google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" }
+google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
+jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
+jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
+jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
+lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }
+markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
+markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
+markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
+trbl-blurhash = { group = "io.trbl", name = "blurhash", version.ref = "blurhash" }
+unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
+url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
+vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" }
+vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" }
+vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" }
+vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" }
+zelory-video-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
+zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
+zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
+zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+androidBenchmark = { id = "androidx.benchmark", version.ref = "benchmark" }
+androidLibrary = { id = "com.android.library", version.ref = "agp" }
+diffplugSpotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
+googleServices = { id = "com.google.gms.google-services", version.ref = "gms" }
+jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index df554462d..c738a045f 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Wed Jan 04 09:23:50 EST 2023
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
diff --git a/quartz/build.gradle b/quartz/build.gradle
index c5ff3ec13..a9aa482f5 100644
--- a/quartz/build.gradle
+++ b/quartz/build.gradle
@@ -1,7 +1,6 @@
plugins {
- id 'com.android.library'
- id 'org.jetbrains.kotlin.android'
- id 'org.jetbrains.kotlin.plugin.serialization'
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.jetbrainsKotlinAndroid)
}
android {
@@ -34,36 +33,39 @@ android {
jvmTarget = '17'
freeCompilerArgs += "-Xstring-concat=inline"
}
-
packagingOptions {
- // delete native scrypt lib because we cannot use dylibs in android
- exclude '**/libscrypt.dylib'
+ resources {
+ excludes += ['**/libscrypt.dylib']
+ }
}
}
dependencies {
- implementation "androidx.core:core-ktx:$core_ktx_version"
+ implementation libs.androidx.core.ktx
+
+ implementation platform(libs.androidx.compose.bom)
// @Immutable and @Stable
- implementation "androidx.compose.runtime:runtime:$compose_ui_version"
+ implementation libs.androidx.runtime.runtime
// Bitcoin secp256k1 bindings to Android
- api 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.14.0'
+ api libs.secp256k1.kmp.jni.android
// LibSodium for ChaCha encryption (NIP-44)
+ // Wait for @aar support in version catalogs
implementation "com.goterl:lazysodium-android:5.1.0@aar"
implementation 'net.java.dev.jna:jna:5.14.0@aar'
// Performant Parser of JSONs into Events
- api 'com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1'
+ api libs.jackson.module.kotlin
// immutable collections to avoid recomposition
- api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7')
+ api libs.kotlinx.collections.immutable
// Parses URLs from Text:
- api "io.github.url-detector:url-detector:0.1.23"
+ api libs.url.detector
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.junit
+ androidTestImplementation libs.androidx.espresso.core
}
\ No newline at end of file
diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt
index 326fe5bb1..8876fd703 100644
--- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt
+++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt
@@ -24,6 +24,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19Bech32
+import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull
+import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -134,19 +136,18 @@ class NIP19EmbedTests {
assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson())
}
- /*
@Test
- fun testCompressionSizes() {
+ fun testVisionPrescriptionBundle2EmbedEvent() {
val signer =
NostrSignerInternal(
- KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
+ KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()),
)
var eyeglassesPrescriptionEvent: Event? = null
val countDownLatch = CountDownLatch(1)
- FhirResourceEvent.create(fhirPayload = visionPrescriptionFhir, signer = signer) {
+ FhirResourceEvent.create(fhirPayload = visionPrescriptionBundle2, signer = signer) {
eyeglassesPrescriptionEvent = it
countDownLatch.countDown()
}
@@ -155,21 +156,20 @@ class NIP19EmbedTests {
assertNotNull(eyeglassesPrescriptionEvent)
- val json = eyeglassesPrescriptionEvent!!.toJson()
+ val bech32 = Nip19Bech32.createNEmbed(eyeglassesPrescriptionEvent!!)
- Bech32.encodeBytes(hrp = "nembed", json.toByteArray(), Bech32.Encoding.Bech32)
- Bech32.encodeBytes(hrp = "nembed", gzip(json), Bech32.Encoding.Bech32)
+ println(eyeglassesPrescriptionEvent!!.toJson())
+ println(bech32)
- val (bech32json, elapsedjson) = measureTimedValue { json.toByteArray().toNEmbed() }
- val (bech32gzip, elapsedgzip) = measureTimedValue { gzip(json).toNEmbed() }
+ val decodedNote = (Nip19Bech32.uriToRoute(bech32)?.entity as Nip19Bech32.NEmbed).event
- println("Raw JSON ${json.length} chars")
- println("Bech32 JSON ${bech32json.length} chars in $elapsedjson")
- println("Bech32 GZIP ${bech32gzip.length} chars in $elapsedgzip")
+ assertTrue(decodedNote.hasValidSignature())
- assertTrue(true)
- }*/
+ assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson())
+ }
val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}"
val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
+
+ val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt
index d4d2bb178..775a7c85f 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt
@@ -30,7 +30,7 @@ class Lud06 {
fun toLud16(str: String): String? {
return try {
- val url = toLnUrlp(str)
+ val url = toLnUrlp(str) ?: return null
val matcher = LNURLP_PATTERN.matcher(url)
if (matcher.find()) {
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt
index 58f43b9dc..c2258dad6 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt
@@ -86,7 +86,7 @@ class RelayInformationLimitation(
)
class RelayInformationRetentionData(
- val kinds: ArrayList,
+ val kinds: ArrayList?,
val tiem: Int?,
val count: Int?,
)
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt
index 0516f1916..31267b345 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt
@@ -94,7 +94,9 @@ object Nip19Bech32 {
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) // additional chars
- return parseComponents(type!!, key, additionalChars)
+ if (type == null) return null
+
+ return parseComponents(type, key, additionalChars)
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
@@ -111,6 +113,7 @@ object Nip19Bech32 {
val bytes = (type + key).bechToBytes()
when (type.lowercase()) {
+ "nsec1" -> nsec(bytes)
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
@@ -133,6 +136,11 @@ object Nip19Bech32 {
return NEmbed(Event.fromJson(ungzip(bytes)))
}
+ private fun nsec(bytes: ByteArray): NSec? {
+ if (bytes.isEmpty()) return null
+ return NSec(bytes.toHexKey())
+ }
+
private fun npub(bytes: ByteArray): NPub? {
if (bytes.isEmpty()) return null
return NPub(bytes.toHexKey())
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt
index 26a522e49..aa65eb642 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt
@@ -55,6 +55,8 @@ open class BaseTextNoteEvent(
fun forkFromVersion() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "fork" }?.get(1)
+ fun isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) }
+
open fun replyTos(): List {
val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] }
val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1)
@@ -105,14 +107,16 @@ open class BaseTextNoteEvent(
val additionalChars = matcher2.group(4) // additional chars
try {
- val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity
+ if (type != null) {
+ val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity
- if (parsed != null) {
- if (parsed is Nip19Bech32.NProfile) {
- returningList.add(parsed.hex)
- }
- if (parsed is Nip19Bech32.NPub) {
- returningList.add(parsed.hex)
+ if (parsed != null) {
+ if (parsed is Nip19Bech32.NProfile) {
+ returningList.add(parsed.hex)
+ }
+ if (parsed is Nip19Bech32.NPub) {
+ returningList.add(parsed.hex)
+ }
}
}
} catch (e: Exception) {
@@ -151,14 +155,16 @@ open class BaseTextNoteEvent(
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
- val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity
+ if (type != null) {
+ val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity
- if (parsed != null) {
- when (parsed) {
- is Nip19Bech32.NEvent -> citations.add(parsed.hex)
- is Nip19Bech32.NAddress -> citations.add(parsed.atag)
- is Nip19Bech32.Note -> citations.add(parsed.hex)
- is Nip19Bech32.NEmbed -> citations.add(parsed.event.id)
+ if (parsed != null) {
+ when (parsed) {
+ is Nip19Bech32.NEvent -> citations.add(parsed.hex)
+ is Nip19Bech32.NAddress -> citations.add(parsed.atag)
+ is Nip19Bech32.Note -> citations.add(parsed.hex)
+ is Nip19Bech32.NEmbed -> citations.add(parsed.event.id)
+ }
}
}
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt
index e57df43d8..b46607c12 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt
@@ -429,25 +429,50 @@ class UserMetadata {
}
fun lnAddress(): String? {
- return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
+ return lud16 ?: lud06
}
fun bestUsername(): String? {
- return name?.ifBlank { null } ?: username?.ifBlank { null }
+ return name ?: username
}
fun bestDisplayName(): String? {
- return displayName?.ifBlank { null }
+ return displayName
}
fun nip05(): String? {
- return nip05?.ifBlank { null }
+ return nip05
}
fun profilePicture(): String? {
- if (picture.isNullOrBlank()) picture = null
return picture
}
+
+ fun cleanBlankNames() {
+ if (picture?.isNotEmpty() == true) picture = picture?.trim()
+ if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim()
+ if (displayName?.isNotEmpty() == true) displayName = displayName?.trim()
+ if (name?.isNotEmpty() == true) name = name?.trim()
+ if (username?.isNotEmpty() == true) username = username?.trim()
+ if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim()
+ if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim()
+
+ if (banner?.isNotEmpty() == true) banner = banner?.trim()
+ if (website?.isNotEmpty() == true) website = website?.trim()
+ if (domain?.isNotEmpty() == true) domain = domain?.trim()
+
+ if (picture?.isBlank() == true) picture = null
+ if (nip05?.isBlank() == true) nip05 = null
+ if (displayName?.isBlank() == true) displayName = null
+ if (name?.isBlank() == true) name = null
+ if (username?.isBlank() == true) username = null
+ if (lud06?.isBlank() == true) lud06 = null
+ if (lud16?.isBlank() == true) lud16 = null
+
+ if (banner?.isBlank() == true) banner = null
+ if (website?.isBlank() == true) website = null
+ if (domain?.isBlank() == true) domain = null
+ }
}
@Stable class ImmutableListOfLists(val lists: Array>)
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt
index aba51ab92..1d8549679 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt
@@ -386,7 +386,7 @@ open class Event(
put("pubkey", pubKey)
put("created_at", createdAt)
put("kind", kind)
- put(
+ replace(
"tags",
factory.arrayNode(tags.size).apply {
tags.forEach { tag ->
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt
index 951901e4e..306277d04 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt
@@ -126,6 +126,7 @@ class EventFactory {
SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig)
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
+ TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig)
VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig)
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt
index 55f0b4b2b..ae2d56396 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt
@@ -63,7 +63,7 @@ interface EventInterface {
fun isTaggedUser(idHex: String): Boolean
- fun isTaggedUsers(idHex: Set): Boolean
+ fun isTaggedUsers(idHexes: Set): Boolean
fun isTaggedEvent(idHex: String): Boolean
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt
index ed11d2015..2c1752ffe 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt
@@ -41,7 +41,7 @@ class GitIssueEvent(
private fun repositoryHex() = innerRepository()?.getOrNull(1)
- fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
+ fun rootIssueOrPatch() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
fun repository() =
innerRepository()?.let {
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt
index d9a6670e1..4c5a04ce8 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt
@@ -60,7 +60,7 @@ class GitPatchEvent(
fun commitPGPSig() = tags.firstOrNull { it.size > 1 && it[0] == "commit-pgp-sig" }?.get(1)
fun committer() =
- tags.filter { it.size > 1 && it[0] == "committer" }?.mapNotNull {
+ tags.filter { it.size > 1 && it[0] == "committer" }.mapNotNull {
Committer(it.getOrNull(1), it.getOrNull(2), it.getOrNull(3), it.getOrNull(4))
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt
index a6b1a5658..f083c895f 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
+import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -57,7 +58,7 @@ class GitReplyEvent(
companion object {
const val KIND = 1622
- const val ALT = "A Git Issue"
+ const val ALT = "A Git Reply"
fun create(
patch: String,
@@ -75,5 +76,87 @@ class GitReplyEvent(
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
+
+ fun create(
+ msg: String,
+ replyTos: List? = null,
+ mentions: List? = null,
+ addresses: List? = null,
+ extraTags: List? = null,
+ zapReceiver: List? = null,
+ markAsSensitive: Boolean = false,
+ zapRaiserAmount: Long? = null,
+ replyingTo: String? = null,
+ root: String? = null,
+ directMentions: Set = emptySet(),
+ geohash: String? = null,
+ nip94attachments: List? = null,
+ forkedFrom: Event? = null,
+ signer: NostrSigner,
+ createdAt: Long = TimeUtils.now(),
+ onReady: (GitReplyEvent) -> Unit,
+ ) {
+ val tags = mutableListOf>()
+ replyTos?.let {
+ tags.addAll(
+ it.positionalMarkedTags(
+ tagName = "e",
+ root = root,
+ replyingTo = replyingTo,
+ directMentions = directMentions,
+ forkedFrom = forkedFrom?.id,
+ ),
+ )
+ }
+ mentions?.forEach {
+ if (it in directMentions) {
+ tags.add(arrayOf("p", it, "", "mention"))
+ } else {
+ tags.add(arrayOf("p", it))
+ }
+ }
+ replyTos?.forEach {
+ if (it in directMentions) {
+ tags.add(arrayOf("q", it))
+ }
+ }
+ addresses
+ ?.map { it.toTag() }
+ ?.let {
+ tags.addAll(
+ it.positionalMarkedTags(
+ tagName = "a",
+ root = root,
+ replyingTo = replyingTo,
+ directMentions = directMentions,
+ forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(),
+ ),
+ )
+ }
+ findHashtags(msg).forEach {
+ tags.add(arrayOf("t", it))
+ tags.add(arrayOf("t", it.lowercase()))
+ }
+ extraTags?.forEach { tags.add(arrayOf("t", it)) }
+ zapReceiver?.forEach {
+ tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
+ }
+ findURLs(msg).forEach { tags.add(arrayOf("r", it)) }
+ if (markAsSensitive) {
+ tags.add(arrayOf("content-warning", ""))
+ }
+ zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
+ geohash?.let { tags.addAll(geohashMipMap(it)) }
+ nip94attachments?.let {
+ it.forEach {
+ Nip92MediaAttachments().convertFromFileHeader(it)?.let {
+ tags.add(it)
+ }
+ }
+ }
+ tags.add(arrayOf("alt", "a git issue reply"))
+
+ signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
+ }
}
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt
index 79a3161b2..13f81f043 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt
@@ -123,45 +123,45 @@ class TextNoteEvent(
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
}
-
- /**
- * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the
- * deprecated method of positional tags to maximize backwards compatibility with clients that
- * support replies but have not been updated to understand tag markers.
- *
- * https://github.com/nostr-protocol/nips/blob/master/10.md
- *
- * The tag to the root of the reply chain goes first. The tag to the reply event being responded
- * to goes last. The order for any other tag does not matter, so keep the relative order.
- */
- private fun List.positionalMarkedTags(
- tagName: String,
- root: String?,
- replyingTo: String?,
- directMentions: Set,
- forkedFrom: String?,
- ) = sortedWith { o1, o2 ->
- when {
- o1 == o2 -> 0
- o1 == root -> -1 // root goes first
- o2 == root -> 1 // root goes first
- o1 == replyingTo -> 1 // reply event being responded to goes last
- o2 == replyingTo -> -1 // reply event being responded to goes last
- else -> 0 // keep the relative order for any other tag
- }
- }
- .map {
- when (it) {
- root -> arrayOf(tagName, it, "", "root")
- replyingTo -> arrayOf(tagName, it, "", "reply")
- forkedFrom -> arrayOf(tagName, it, "", "fork")
- in directMentions -> arrayOf(tagName, it, "", "mention")
- else -> arrayOf(tagName, it)
- }
- }
}
}
fun findURLs(text: String): List {
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
}
+
+/**
+ * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the
+ * deprecated method of positional tags to maximize backwards compatibility with clients that
+ * support replies but have not been updated to understand tag markers.
+ *
+ * https://github.com/nostr-protocol/nips/blob/master/10.md
+ *
+ * The tag to the root of the reply chain goes first. The tag to the reply event being responded
+ * to goes last. The order for any other tag does not matter, so keep the relative order.
+ */
+fun List.positionalMarkedTags(
+ tagName: String,
+ root: String?,
+ replyingTo: String?,
+ directMentions: Set,
+ forkedFrom: String?,
+) = sortedWith { o1, o2 ->
+ when {
+ o1 == o2 -> 0
+ o1 == root -> -1 // root goes first
+ o2 == root -> 1 // root goes first
+ o1 == replyingTo -> 1 // reply event being responded to goes last
+ o2 == replyingTo -> -1 // reply event being responded to goes last
+ else -> 0 // keep the relative order for any other tag
+ }
+}
+ .map {
+ when (it) {
+ root -> arrayOf(tagName, it, "", "root")
+ replyingTo -> arrayOf(tagName, it, "", "reply")
+ forkedFrom -> arrayOf(tagName, it, "", "fork")
+ in directMentions -> arrayOf(tagName, it, "", "mention")
+ else -> arrayOf(tagName, it)
+ }
+ }
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt
new file mode 100644
index 000000000..0094c5983
--- /dev/null
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.events
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.encoders.HexKey
+import com.vitorpamplona.quartz.signers.NostrSigner
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+@Immutable
+class TextNoteModificationEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
+ fun editedNote() = firstTaggedEvent()
+
+ fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
+
+ companion object {
+ const val KIND = 1010
+ const val ALT = "Content Change Event"
+
+ fun create(
+ content: String,
+ eventId: HexKey,
+ notify: HexKey?,
+ summary: String?,
+ signer: NostrSigner,
+ createdAt: Long = TimeUtils.now(),
+ onReady: (TextNoteModificationEvent) -> Unit,
+ ) {
+ val tags = mutableListOf(arrayOf("e", eventId))
+
+ notify?.let {
+ tags.add(arrayOf("p", it))
+ }
+
+ summary?.let {
+ tags.add(arrayOf("summary", it))
+ }
+
+ tags.add(arrayOf("alt", ALT))
+
+ signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
+ }
+ }
+}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt
index bff1e2eb8..0d9c1f252 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt
@@ -33,8 +33,8 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.HexKey
-import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LnZapRequestEvent
@@ -90,9 +90,7 @@ class Result(
* Parses the json with a string of events to an Array of Event objects.
*/
fun fromJsonArray(json: String): Array {
- return Event.mapper.readTree(json).map {
- fromJson(it.asText())
- }.toTypedArray()
+ return mapper.readValue(json)
}
}
}
diff --git a/settings.gradle b/settings.gradle
index 6ac181c95..45a4461c7 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,8 +1,14 @@
pluginManagement {
repositories {
- gradlePluginPortal()
- google()
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
mavenCentral()
+ gradlePluginPortal()
maven {
url "https://jitpack.io"
content {
@@ -11,6 +17,7 @@ pluginManagement {
}
}
}
+
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@@ -19,6 +26,7 @@ dependencyResolutionManagement {
maven { url "https://jitpack.io" }
}
}
+
rootProject.name = "Amethyst"
include ':app'
include ':benchmark'