kopia lustrzana https://github.com/vitorpamplona/amethyst
Merge branch 'main' into main
commit
4938ba03a6
|
@ -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 }}
|
23
README.md
23
README.md
|
@ -1,5 +1,5 @@
|
|||
<div align="center">
|
||||
|
||||
|
||||
<a href="https://amethyst.social">
|
||||
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
|
||||
</a>
|
||||
|
@ -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)
|
||||
|
||||
|
|
135
app/build.gradle
135
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
|
||||
|
|
|
@ -34,6 +34,7 @@ fun TranslatableRichTextViewer(
|
|||
modifier: Modifier = Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) = ExpandableRichTextViewer(
|
||||
|
@ -42,6 +43,7 @@ fun TranslatableRichTextViewer(
|
|||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
id,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
|
|
@ -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<Note>?,
|
||||
mentions: List<User>?,
|
||||
repository: ATag?,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = 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<Note>?,
|
||||
|
@ -1405,6 +1464,29 @@ class Account(
|
|||
}
|
||||
}
|
||||
|
||||
fun sendEdit(
|
||||
message: String,
|
||||
originalNote: Note,
|
||||
notify: HexKey?,
|
||||
summary: String? = null,
|
||||
relayList: List<Relay>? = 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<Note>?,
|
||||
|
|
|
@ -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<HexKey, List<Note>>(20)
|
||||
|
||||
fun cachedModificationEventsForNote(note: Note): List<Note>? {
|
||||
return modificationCache[note.idHex]
|
||||
}
|
||||
|
||||
suspend fun findLatestModificationForNote(note: Note): List<Note> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String, Resource> = persistentMapOf(),
|
||||
)
|
||||
|
||||
fun findReferenceInDb(
|
||||
it: String,
|
||||
db: Map<String, Resource>,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.RichTextViewerState
|
|||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(50)
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
|
|
|
@ -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<String, GenericLoadable<CashuToken>>(20)
|
||||
|
||||
fun cached(token: String): GenericLoadable<CashuToken> {
|
||||
return cashuCache[token] ?: GenericLoadable.Loading()
|
||||
}
|
||||
|
||||
fun parse(token: String): GenericLoadable<CashuToken> {
|
||||
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<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
|
|
@ -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<String, String>(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,
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<Note>()
|
||||
|
@ -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),
|
||||
|
|
|
@ -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<String, InvoiceAmount>(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
|
||||
}
|
||||
}
|
|
@ -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) } },
|
||||
)
|
||||
|
|
|
@ -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<TypedFilter>?): 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FeedType>,
|
||||
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<FeedType>): 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<List<FileHeaderEvent>>(emptyList())
|
||||
var nip95attachments by
|
||||
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(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<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<Relay>? = 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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<Flow<String>?>(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<String>) {
|
||||
val location by geoLocation.collectAsStateWithLifecycle(null)
|
||||
fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
|
||||
val context = LocalContext.current
|
||||
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(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<String>(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,
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -68,6 +68,7 @@ fun NotifyRequestDialog(
|
|||
Modifier.fillMaxWidth(),
|
||||
EmptyTagList,
|
||||
background,
|
||||
textContent,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
|
|
@ -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<CashuToken>>(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<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<CashuToken> ->
|
||||
|
@ -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)) {
|
||||
|
|
|
@ -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<String, Boolean>(20)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
|
@ -58,10 +63,19 @@ fun ExpandableRichTextViewer(
|
|||
modifier: Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<InvoiceAmount?>(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 =
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<Color>,
|
||||
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,
|
||||
|
|
|
@ -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()),
|
||||
) {
|
||||
|
|
|
@ -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 <T> 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 <T> SpinnerSelectionDialog(
|
|||
Column { onRenderItem(item) }
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = DividerThickness)
|
||||
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<Note>()
|
|||
): 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) {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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<String, Note?> = mapOf()
|
||||
|
||||
|
|
|
@ -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<Color>(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,
|
||||
)
|
||||
|
|
|
@ -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<Note>(),
|
||||
isHiddenAuthor = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {}
|
||||
},
|
||||
onLight = {
|
||||
HiddenNote(
|
||||
reports = persistentSetOf<Note>(),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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<AddressableNote?>(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<AddressableNote?>(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<AddressableNote>) -> Unit,
|
||||
) {
|
||||
var statuses: ImmutableList<AddressableNote> 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<Long> 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<Channel?>(accountViewModel.getChannelIfExists(baseChannelHex))
|
||||
}
|
||||
|
||||
if (channel == null) {
|
||||
LaunchedEffect(key1 = baseChannelHex) {
|
||||
accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
|
||||
launch(Dispatchers.Main) { channel = newChannel }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channel?.let { content(it) }
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -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,
|
||||
|
|
|
@ -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<String>,
|
||||
|
@ -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,
|
||||
|
|
|
@ -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<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
|
||||
var tally: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
|
||||
var consensusThreadhold: MutableState<Boolean> = mutableStateOf(false),
|
||||
var zappedByLoggedIn: MutableState<Boolean> = 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<List<PollOption>>(emptyList())
|
||||
val tallies = _tallies.asStateFlow()
|
||||
var canZap = mutableStateOf(false)
|
||||
var tallies: List<PollOption> = 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
|
||||
|
|
|
@ -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<GenericLoadable<EditState>>,
|
||||
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<Boolean>,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
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<GenericLoadable<EditState>>,
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf<DropDownParams>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String>?,
|
||||
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<String>?,
|
||||
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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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<String?>(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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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<GenericLoadable<EditState>>? = 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<Boolean>,
|
||||
editState: State<GenericLoadable<EditState>>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf<DropDownParams>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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<UserMetadata?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<List<Pair<Participant, User>>>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<SuccessResult?>(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<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
if (note.replyTo.isNullOrEmpty()) return
|
||||
|
||||
val noteEvent = note.event as? BadgeAwardEvent ?: return
|
||||
var awardees by remember { mutableStateOf<List<User>>(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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ImmutableList<Pair<Participant, User>>>(
|
||||
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)
|
||||
}
|
|
@ -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<Color>,
|
||||
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<Color>,
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<BaseMediaContent>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<BaseMediaContent?>(newContent)
|
||||
}
|
||||
|
||||
Crossfade(targetState = content) {
|
||||
if (it != null) {
|
||||
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = it,
|
||||
roundedCorner = roundedCorner,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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<Color>,
|
||||
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<Color>,
|
||||
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<Color>,
|
||||
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://"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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<Color>,
|
||||
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<User?>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ImmutableList<Pair<Participant, User>>>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String, Resource>,
|
||||
) {
|
||||
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)
|
||||
}
|
|
@ -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<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? PeopleListEvent ?: return
|
||||
|
||||
var members by remember { mutableStateOf<ImmutableList<User>>(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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<Color>,
|
||||
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,
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue