diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 65829028f..77c10ab3f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -228,8 +228,3 @@ jobs: asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_content_type: application/zip - - - name: Drafts a description for the release - uses: release-drafter/release-drafter@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 51a530e1d..9149eb6dd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- + Amethyst Logo @@ -40,7 +40,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Events / Relay Subscriptions (NIP-01) - [x] Follow List (NIP-02) -- [ ] OpenTimestamps Attestations (NIP-03) +- [x] OpenTimestamps Attestations (NIP-03) - [x] Private Messages (NIP-04) - [x] DNS Address (NIP-05) - [ ] Mnemonic seed phrase (NIP-06) @@ -70,7 +70,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Event kind summaries (NIP-31) - [ ] Labeling (NIP-32) - [x] Parameterized Replaceable Events (NIP-33) +- [x] Git Stuff (NIP-34/Draft) - [x] Sensitive Content (NIP-36) +- [x] Note Edits (NIP-37/Draft) - [x] User Status Event (NIP-38) - [x] External Identities (NIP-39) - [x] Expiration Support (NIP-40) @@ -109,7 +111,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Classifieds (NIP-99) - [x] Private Messages and Small Groups (NIP-24/Draft) - [x] Versioned Encrypted Payloads (NIP-44/Draft) -- [x] Audio Tracks (zapstr.live) (Kind:31337) +- [x] Audio Tracks (zapstr.live) (kind:31337) - [x] Push Notifications (Google and Unified Push) - [x] In-Device Automatic Translations - [x] Hashtag Following and Custom Hashtags @@ -118,6 +120,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] De-googled F-Droid flavor - [x] Multiple Accounts - [x] Markdown Support +- [x] FHIR Payloads (kind:82) +- [ ] Decentralized Wiki (kind:30818) +- [ ] Embed events - [ ] Image/Video Capture in the app - [ ] Local Database - [ ] Workspaces @@ -135,16 +140,16 @@ Information shared on Nostr can be re-broadcasted to other servers and should be # Development Overview -This repository is split between Amethyst and Quartz: +This repository is split between Amethyst and Quartz: - Amethyst is a native Android app made with Kotlin and Jetpack Compose. -- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients. +- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients. The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays, and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph. The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow objects to allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen. -They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes +They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes are updated to receive more information about those particular Events. Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app, @@ -236,12 +241,14 @@ dependencyResolutionManagement { Add the dependency ```gradle -implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3') +implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1') ``` ## Contributing -[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are very welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social) +Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst) + +[GitHub issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social) You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac) diff --git a/app/build.gradle b/app/build.gradle index 7f6f5be40..e266a4e6e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'com.google.gms.google-services' + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.googleServices) } android { @@ -12,9 +12,9 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 358 - versionName "0.84.3" - buildConfigField "String", "RELEASE_NOTES_ID", "\"4d5a05aec61d8798f30f76b2efab81b98d75a03f935fb82823a1080bd56473cd\"" + versionCode 362 + versionName "0.85.3" + buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -145,14 +145,13 @@ android { // Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin kotlinCompilerExtensionVersion "1.5.8" } - packagingOptions { resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' + excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib'] } - exclude '**/libscrypt.dylib' } + lint { disable 'MissingTranslation' } @@ -165,75 +164,78 @@ android { dependencies { implementation project(path: ':quartz') implementation project(path: ':commons') - implementation "androidx.core:core-ktx:$core_ktx_version" - implementation 'androidx.activity:activity-compose:1.8.2' - implementation "androidx.compose.ui:ui:$compose_ui_version" - implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" + implementation libs.androidx.core.ktx + implementation libs.androidx.activity.compose + + implementation platform(libs.androidx.compose.bom) + + implementation libs.androidx.ui + implementation libs.androidx.ui.graphics + implementation libs.androidx.ui.tooling.preview // Needs this to open gallery / image upload - implementation "androidx.fragment:fragment-ktx:$fragment_version" + implementation libs.androidx.fragment.ktx // Navigation - implementation "androidx.navigation:navigation-compose:$nav_version" + implementation libs.androidx.navigation.compose // Observe Live data as State - implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version" + implementation libs.androidx.runtime.livedata // Material 3 Design - implementation "androidx.compose.material3:material3:${material3_version}" - implementation "androidx.compose.material:material-icons-extended:$compose_ui_version" + implementation libs.androidx.material3 + implementation libs.androidx.material.icons // Adaptive Layout / Two Pane - implementation "androidx.compose.material3:material3-window-size-class:${material3_version}" - implementation 'com.google.accompanist:accompanist-adaptive:0.34.0' - + implementation libs.androidx.material3.windowSize + implementation libs.accompanist.adaptive // Lifecycle - implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + implementation libs.androidx.lifecycle.runtime.ktx + implementation libs.androidx.lifecycle.runtime.compose + implementation libs.androidx.lifecycle.viewmodel.compose + implementation libs.androidx.lifecycle.livedata.ktx // Zoomable images - implementation 'net.engawapg.lib:zoomable:1.6.0' + implementation libs.zoomable // Biometrics - implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" + implementation libs.androidx.biometric.ktx // Websockets API - implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12' + implementation libs.okhttp // HTML Parsing for Link Preview - implementation 'org.jsoup:jsoup:1.17.2' + implementation libs.jsoup // Encrypted Key Storage - implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' + implementation libs.androidx.security.crypto.ktx // view videos - implementation "androidx.media3:media3-exoplayer:$media3_version" - implementation "androidx.media3:media3-exoplayer-hls:$media3_version" - implementation "androidx.media3:media3-ui:$media3_version" - implementation "androidx.media3:media3-session:$media3_version" + implementation libs.androidx.media3.exoplayer + implementation libs.androidx.media3.exoplayer.hls + implementation libs.androidx.media3.ui + implementation libs.androidx.media3.session // important for proxy / tor - implementation "androidx.media3:media3-datasource-okhttp:$media3_version" + implementation libs.androidx.media3.datasource.okhttp // Load images from the web. - implementation "io.coil-kt:coil-compose:$coil_version" + implementation libs.coil.compose // view gifs - implementation "io.coil-kt:coil-gif:$coil_version" + implementation libs.coil.gif // view svgs - implementation "io.coil-kt:coil-svg:$coil_version" + implementation libs.coil.svg // create blurhash - implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0' + implementation libs.trbl.blurhash // Permission to upload pictures: - implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" + implementation libs.accompanist.permissions // For QR generation - implementation 'com.google.zxing:core:3.5.3' - implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + implementation libs.zxing + implementation libs.zxing.embedded // Markdown //implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" @@ -241,51 +243,50 @@ dependencies { //implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0" // Markdown (With fix for full-image bleeds) - implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced') - implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced') - implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced') + implementation libs.markdown.ui + implementation libs.markdown.ui.material3 + implementation libs.markdown.commonmark // Language picker and Theme chooser - implementation 'androidx.appcompat:appcompat:1.6.1' + implementation libs.androidx.appcompat // Local model for language identification - playImplementation 'com.google.mlkit:language-id:17.0.4' + playImplementation libs.google.mlkit.language.id // Google services model the translate text - playImplementation 'com.google.mlkit:translate:17.0.2' + playImplementation libs.google.mlkit.translate // PushNotifications - playImplementation platform('com.google.firebase:firebase-bom:32.7.2') - playImplementation 'com.google.firebase:firebase-messaging-ktx' + playImplementation platform(libs.firebase.bom) + playImplementation libs.firebase.messaging //PushNotifications(FDroid) - fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0' + fdroidImplementation libs.unifiedpush // Charts - implementation "com.patrykandpatrick.vico:core:${vico_version}" - implementation "com.patrykandpatrick.vico:compose:${vico_version}" - implementation "com.patrykandpatrick.vico:views:${vico_version}" - implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}" + implementation libs.vico.charts.core + implementation libs.vico.charts.compose + implementation libs.vico.charts.views + implementation libs.vico.charts.m3 // GeoHash - implementation 'com.github.drfonfon:android-kotlin-geohash:1.0' + implementation libs.drfonfon.geohash // Waveform visualizer - implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1' + implementation libs.audiowaveform // Video compression lib - implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2' + implementation libs.abedElazizShe.image.compressor // Image compression lib - implementation 'id.zelory:compressor:3.0.1' + implementation libs.zelory.video.compressor - testImplementation 'junit:junit:4.13.2' - testImplementation 'io.mockk:mockk:1.13.9' - androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" - debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" + testImplementation libs.junit + testImplementation libs.mockk + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.junit.ktx + androidTestImplementation libs.androidx.espresso.core + debugImplementation libs.androidx.ui.tooling + debugImplementation libs.androidx.ui.test.manifest } // https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index fe225dcaf..b5f4b216b 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -34,6 +34,7 @@ fun TranslatableRichTextViewer( modifier: Modifier = Modifier, tags: ImmutableListOfLists, backgroundColor: MutableState, + id: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) = ExpandableRichTextViewer( @@ -42,6 +43,7 @@ fun TranslatableRichTextViewer( modifier, tags, backgroundColor, + id, accountViewModel, nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c7421c35f..039090ac7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GeneralListEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent @@ -89,6 +90,7 @@ import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.signers.NostrSigner @@ -1348,6 +1350,63 @@ class Account( } } + fun sendGitReply( + message: String, + replyTo: List?, + mentions: List?, + repository: ATag?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + replyingTo: String?, + root: String?, + directMentions: Set, + forkedFrom: Event?, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList()) + + GitReplyEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = null, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + replyingTo = replyingTo, + root = root, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + forkedFrom = forkedFrom, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + fun sendPost( message: String, replyTo: List?, @@ -1405,6 +1464,29 @@ class Account( } } + fun sendEdit( + message: String, + originalNote: Note, + notify: HexKey?, + summary: String? = null, + relayList: List? = null, + ) { + if (!isWriteable()) return + + val idHex = originalNote.event?.id() ?: return + + TextNoteModificationEvent.create( + content = message, + eventId = idHex, + notify = notify, + summary = summary, + signer = signer, + ) { + LocalCache.justConsume(it, null) + Client.send(it, relayList = relayList) + } + } + fun sendPoll( message: String, replyTo: List?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f366531a5..aa25df2be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import android.util.Log +import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -98,6 +99,7 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent @@ -449,14 +451,21 @@ object LocalCache { return } + val repository = event.repository()?.toTag() + val replyTo = event .tagsWithoutCitations() - .filter { it != event.repository()?.toTag() } + .filter { it != repository } .mapNotNull { checkGetOrCreateNote(it) } + // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}") + note.loadEvent(event, author, replyTo) + // Counts the replies + replyTo.forEach { it.addReply(note) } + refreshObservers(note) } @@ -745,7 +754,9 @@ object LocalCache { if (version.event == null) { version.loadEvent(event, author, emptyList()) - + if (version.liveSet != null) { + updateListCache() + } version.liveSet?.innerOts?.invalidateData() } @@ -1384,6 +1395,37 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: TextNoteModificationEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + event.editedNote()?.let { + checkGetOrCreateNote(it)?.let { editedNote -> + modificationCache.remove(editedNote.idHex) + // must update list of Notes to quickly update the user. + if (editedNote.liveSet != null) { + updateListCache() + } + editedNote.liveSet?.innerModifications?.invalidateData() + } + } + + refreshObservers(note) + } + fun consume( event: HighlightEvent, relay: Relay?, @@ -1613,6 +1655,8 @@ object LocalCache { it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && + it.event !is SealedGossipEvent && + it.event !is OtsEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent ) && @@ -1695,6 +1739,35 @@ object LocalCache { return minTime } + val modificationCache = LruCache>(20) + + fun cachedModificationEventsForNote(note: Note): List? { + return modificationCache[note.idHex] + } + + suspend fun findLatestModificationForNote(note: Note): List { + checkNotInMainThread() + + val originalAuthor = note.author?.pubkeyHex ?: return emptyList() + + modificationCache[note.idHex]?.let { + return it + } + + val time = TimeUtils.now() + + val newNotes = + noteListCache.filter { item -> + val noteEvent = item.event + + noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + + modificationCache.put(note.idHex, newNotes) + + return newNotes + } + fun cleanObservers() { noteListCache.forEach { it.clearLive() } @@ -2048,6 +2121,7 @@ object LocalCache { } is StatusEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay) + is TextNoteModificationEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay) is WikiNoteEvent -> consume(event, relay) @@ -2073,8 +2147,8 @@ class LocalCacheLiveData { fun invalidateData(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> - _newEventBundles.emit(bundledNewNotes) LocalCache.updateListCache() + _newEventBundles.emit(bundledNewNotes) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt similarity index 75% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt rename to app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt index b782a1c19..6bf60d156 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt @@ -18,10 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note +package com.vitorpamplona.amethyst.model +import android.util.Log import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -110,6 +116,11 @@ class Reference( var reference: String? = null, ) +data class FhirElementDatabase( + var baseResource: Resource? = null, + var localDb: ImmutableMap = persistentMapOf(), +) + fun findReferenceInDb( it: String, db: Map, @@ -121,3 +132,30 @@ fun findReferenceInDb( db.get(it.removePrefix("#")) } } + +fun parseResourceBundleOrNull(json: String): FhirElementDatabase? { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + return try { + val resource = mapper.readValue(json, Resource::class.java) + + val db = + when (resource) { + is Bundle -> { + resource.entry.associateBy { it.id }.toImmutableMap() + } + else -> { + persistentMapOf(resource.id to resource) + } + } + + FhirElementDatabase( + localDb = db, + baseResource = resource, + ) + } catch (e: Exception) { + Log.e("RenderEyeGlassesPrescription", "Parser error", e) + null + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 73526c322..77f380b3c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -948,6 +948,7 @@ class NoteLiveSet(u: Note) { val innerRelays = NoteBundledRefresherLiveData(u) val innerZaps = NoteBundledRefresherLiveData(u) val innerOts = NoteBundledRefresherLiveData(u) + val innerModifications = NoteBundledRefresherLiveData(u) val metadata = innerMetadata.map { it } val reactions = innerReactions.map { it } @@ -1001,7 +1002,9 @@ class NoteLiveSet(u: Note) { hasReactions.hasObservers() || replyCount.hasObservers() || reactionCount.hasObservers() || - boostCount.hasObservers() + boostCount.hasObservers() || + innerOts.hasObservers() || + innerModifications.hasObservers() } fun destroy() { @@ -1013,6 +1016,7 @@ class NoteLiveSet(u: Note) { innerRelays.destroy() innerZaps.destroy() innerOts.destroy() + innerModifications.destroy() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 30ded640a..52ccbacd5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -310,6 +310,7 @@ class User(val pubkeyHex: String) { info?.latestMetadata = latestMetadata info?.updatedMetadataAt = latestMetadata.createdAt info?.tags = latestMetadata.tags.toImmutableListOfLists() + info?.cleanBlankNames() if (newUserInfo.lud16.isNullOrBlank()) { info?.lud06?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 25e6c6050..0598ee30c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.RichTextViewerState import com.vitorpamplona.quartz.events.ImmutableListOfLists object CachedRichTextParser { - val richTextCache = LruCache(200) + val richTextCache = LruCache(50) fun parseText( content: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index ecd1918f7..ce3b280c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service import android.content.Context +import android.util.LruCache import androidx.compose.runtime.Immutable import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -42,6 +43,24 @@ data class CashuToken( val proofs: JsonNode, ) +object CachedCashuProcessor { + val cashuCache = LruCache>(20) + + fun cached(token: String): GenericLoadable { + return cashuCache[token] ?: GenericLoadable.Loading() + } + + fun parse(token: String): GenericLoadable { + if (cashuCache[token] !is GenericLoadable.Loaded) { + val newCachuData = CashuProcessor().parse(token) + + cashuCache.put(token, newCachuData) + } + + return cashuCache[token] + } +} + class CashuProcessor { fun parse(cashuToken: String): GenericLoadable { checkNotInMainThread() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt index cd741c52a..cf02079bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt @@ -27,6 +27,7 @@ import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.HandlerThread +import android.util.LruCache import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import kotlinx.coroutines.CancellationException @@ -92,7 +93,33 @@ class LocationUtil(context: Context) { } } -class ReverseGeoLocationUtil { +object CachedGeoLocations { + val locationNames = LruCache(20) + + fun cached(geoHashStr: String): String? { + return locationNames[geoHashStr] + } + + suspend fun geoLocate( + geoHashStr: String, + location: Location, + context: Context, + ): String? { + locationNames[geoHashStr]?.let { + return it + } + + val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null } + + if (name != null) { + locationNames.put(geoHashStr, name) + } + + return name + } +} + +private class ReverseGeoLocationUtil { suspend fun execute( location: Location, context: Context, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index cedf0bd4f..0e49e30bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -35,6 +35,9 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BookmarkListEvent +import com.vitorpamplona.quartz.events.CalendarDateSlotEvent +import com.vitorpamplona.quartz.events.CalendarRSVPEvent +import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent @@ -42,6 +45,10 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.GitReplyEvent +import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.events.MetadataEvent @@ -120,24 +127,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ) } - fun createAccountAcceptedAwardsFilter(): TypedFilter { + fun createAccountSettingsFilter(): TypedFilter { return TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 10, - ), - ) - } - - fun createAccountBookmarkListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND), + kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), limit = 100, ), @@ -204,6 +199,36 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ) } + fun createNotificationFilter2(): TypedFilter { + val since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultNotificationFollowList.value) + ?.relayList + ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + GitReplyEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + HighlightEvent.KIND, + CalendarDateSlotEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarRSVPEvent.KIND, + ), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + limit = 400, + since = since, + ), + ) + } + fun createGiftWrapsToMeFilter() = TypedFilter( types = COMMON_FEED_TYPES, @@ -297,10 +322,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountContactListFilter(), createAccountRelayListFilter(), createNotificationFilter(), + createNotificationFilter2(), createGiftWrapsToMeFilter(), createAccountReportsFilter(), - createAccountAcceptedAwardsFilter(), - createAccountBookmarkListFilter(), + createAccountSettingsFilter(), createAccountLastPostsListFilter(), createOtherAccountsBaseFilter(), ) @@ -312,7 +337,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountMetadataFilter(), createAccountContactListFilter(), createAccountRelayListFilter(), - createAccountBookmarkListFilter(), + createAccountSettingsFilter(), ) .ifEmpty { null } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 47cb0af5d..0a1623e01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -221,7 +221,7 @@ abstract class NostrDataSource(val debugName: String) { // saves the channels that are currently active val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } + val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } changingFilters.getAndSet(true) @@ -245,7 +245,7 @@ abstract class NostrDataSource(val debugName: String) { Client.close(updatedSubscription.id) } else { // was active and is still active, check if it has changed. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { Client.close(updatedSubscription.id) if (active) { Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) @@ -265,7 +265,7 @@ abstract class NostrDataSource(val debugName: String) { // was not active and is still not active, does nothing } else { // was not active and becomes active, sends the filter. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { if (active) { Log.d( this@NostrDataSource.javaClass.simpleName, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index e29566892..659314695 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.OtsEvent @@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() @@ -136,6 +138,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.KIND, PollNoteEvent.KIND, OtsEvent.KIND, + TextNoteModificationEvent.KIND, + GitReplyEvent.KIND, ), tags = mapOf("e" to it.map { it.idHex }), since = findMinimumEOSEs(it), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt new file mode 100644 index 000000000..67a7edcc4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lnurl + +import android.util.LruCache +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.encoders.LnInvoiceUtil +import kotlinx.coroutines.CancellationException +import java.text.NumberFormat + +@Stable +data class InvoiceAmount(val invoice: String, val amount: String?) + +object CachedLnInvoiceParser { + val lnInvoicesCache = LruCache(20) + + fun cached(lnurl: String): InvoiceAmount? { + return lnInvoicesCache[lnurl] + } + + fun parse(lnbcWord: String): InvoiceAmount? { + val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) + if (myInvoice != null) { + val myInvoiceAmount = + try { + NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) + } catch (e: Exception) { + if (e is CancellationException) throw e + e.printStackTrace() + null + } + + val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) + + lnInvoicesCache.put(lnbcWord, lnInvoice) + + return lnInvoice + } + + return null + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index dc7d66d9b..eac7b0f8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -37,26 +37,26 @@ class JsonFilter( val filter = factory.objectNode().apply { ids?.run { - put( + replace( "ids", factory.arrayNode(ids.size).apply { ids.forEach { add(it) } }, ) } authors?.run { - put( + replace( "authors", factory.arrayNode(authors.size).apply { authors.forEach { add(it) } }, ) } kinds?.run { - put( + replace( "kinds", factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } }, ) } tags?.run { entries.forEach { kv -> - put( + replace( "#${kv.key}", factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt index eefe41b99..ebd0ce388 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relays -import com.fasterxml.jackson.databind.JsonNode -import com.vitorpamplona.quartz.events.Event import java.util.UUID data class Subscription( @@ -37,23 +35,36 @@ data class Subscription( onEOSE?.let { it(time, relay) } } - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) - } + fun hasChangedFiltersFrom(otherFilters: List?): Boolean { + if (typedFilters == null && otherFilters == null) return false + if (typedFilters?.size != otherFilters?.size) return true - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory + typedFilters?.forEachIndexed { index, typedFilter -> + val otherFilter = otherFilters?.getOrNull(index) ?: return true - return factory.objectNode().apply { - put("id", id) - typedFilters?.also { filters -> - put( - "typedFilters", - factory.arrayNode(filters.size).apply { - filters.forEach { filter -> add(filter.toJsonObject()) } - }, - ) + // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. + // fast check + if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size || + typedFilter.filter.ids?.size != otherFilter.filter.ids?.size || + typedFilter.filter.tags?.size != otherFilter.filter.tags?.size || + typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size || + typedFilter.filter.limit != otherFilter.filter.limit || + typedFilter.filter.search?.length != otherFilter.filter.search?.length || + typedFilter.filter.until != otherFilter.filter.until + ) { + return true + } + + // deep check + if (typedFilter.filter.ids != otherFilter.filter.ids || + typedFilter.filter.authors != otherFilter.filter.authors || + typedFilter.filter.tags != otherFilter.filter.tags || + typedFilter.filter.kinds != otherFilter.filter.kinds || + typedFilter.filter.search != otherFilter.filter.search + ) { + return true } } + return false } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt index 84ef11d26..4817e7b30 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt @@ -20,73 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relays -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ArrayNode -import com.vitorpamplona.quartz.events.Event - class TypedFilter( val types: Set, val filter: JsonFilter, -) { - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) - } - - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory - - return factory.objectNode().apply { - put("types", typesToJson(types)) - put("filter", filterToJson(filter)) - } - } - - fun typesToJson(types: Set): ArrayNode { - val factory = Event.mapper.nodeFactory - return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } } - } - - fun filterToJson(filter: JsonFilter): JsonNode { - val factory = Event.mapper.nodeFactory - return factory.objectNode().apply { - filter.ids?.run { - put( - "ids", - factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } }, - ) - } - filter.authors?.run { - put( - "authors", - factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } }, - ) - } - filter.kinds?.run { - put( - "kinds", - factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } }, - ) - } - filter.tags?.run { - entries.forEach { kv -> - put( - "#${kv.key}", - factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, - ) - } - } - /* - Does not include since in the json comparison - filter.since?.run { - val jsonObjectSince = JsonObject() - entries.forEach { sincePairs -> - jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}") - } - jsonObject.add("since", jsonObjectSince) - }*/ - filter.until?.run { put("until", filter.until) } - filter.limit?.run { put("limit", filter.limit) } - filter.search?.run { put("search", filter.search) } - } - } -} +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt new file mode 100644 index 000000000..70d6db904 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -0,0 +1,555 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.components.BechLink +import com.vitorpamplona.amethyst.ui.components.InvoiceRequest +import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditPostView( + onClose: () -> Unit, + edit: Note, + versionLookingAt: Note?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val postViewModel: EditPostViewModel = viewModel() + postViewModel.prepare(edit, versionLookingAt, accountViewModel) + + val context = LocalContext.current + + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + + LaunchedEffect(Unit) { + postViewModel.load(edit, versionLookingAt, accountViewModel) + + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } + } + + DisposableEffect(Unit) { + NostrSearchEventOrUserDataSource.start() + + onDispose { + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = stringResource(id = R.string.relay_list_selector), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + PostButton( + onPost = { + postViewModel.sendPost(relayList = relayList) + scope.launch { + delay(100) + onClose() + } + }, + isActive = postViewModel.canPost(), + ) + } + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() + } + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding(), + ) + .fillMaxSize(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + Column( + modifier = + Modifier + .imePadding() + .weight(1f), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + postViewModel.editedFromNote?.let { + Row(Modifier.heightIn(max = 200.dp)) { + NoteCompose( + baseNote = it, + makeItShort = true, + unPackReply = false, + isQuotedNote = true, + modifier = MaterialTheme.colorScheme.replyModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + } + + MessageField(postViewModel) + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (RichTextParser.isValidURL(myUrlPreview)) { + if (RichTextParser.isImageUrl(myUrlPreview)) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) + } else if (RichTextParser.isVideoUrl(myUrlPreview)) { + VideoView( + myUrlPreview, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } else { + LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { + LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + } + + val url = postViewModel.contentToAddUrl + if (url != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ImageVideoDescription( + url, + accountViewModel.account.defaultFileServer, + onAdd = { alt, server, sensitiveContent -> + postViewModel.upload(url, alt, sensitiveContent, false, server, context) + if (!server.isNip95) { + accountViewModel.account.changeDefaultFileServer(server.server) + } + }, + onCancel = { postViewModel.contentToAddUrl = null }, + onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } }, + accountViewModel = accountViewModel, + ) + } + } + + val user = postViewModel.account?.userProfile() + val lud16 = user?.info?.lnAddress() + + if (lud16 != null && postViewModel.wantsInvoice) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + user.pubkeyHex, + accountViewModel.account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.message = + TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.wantsInvoice = false + }, + onClose = { postViewModel.wantsInvoice = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } + + /* + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column { + Text( + text = stringResource(R.string.message_to_author), + fontSize = 18.sp, + fontWeight = FontWeight.W500, + ) + + HorizontalDivider(thickness = DividerThickness) + + MyTextField( + value = postViewModel.subject, + onValueChange = { postViewModel.updateSubject(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.message_to_author_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + }*/ + } + } + + ShowUserSuggestionListForEdit( + postViewModel, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + + BottomRowActions(postViewModel) + } + } + } + } + } +} + +@Composable +fun ShowUserSuggestionListForEdit( + editPostViewModel: EditPostViewModel, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), +) { + val userSuggestions = editPostViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = + PaddingValues( + top = 10.dp, + ), + modifier = modifier, + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex }, + ) { _, item -> + UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) } + } + } + } +} + +@Composable +private fun BottomRowActions(postViewModel: EditPostViewModel) { + val scrollState = rememberScrollState() + + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} + +@Composable +private fun MessageField(postViewModel: EditPostViewModel) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() + } + } + + OutlinedTextField( + value = postViewModel.message, + onValueChange = { postViewModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + ) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.what_s_on_your_mind), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} + +@Composable +private fun AddLnInvoiceButton( + isLnInvoiceActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + contentDescription = stringResource(id = R.string.add_bitcoin_invoice), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt new file mode 100644 index 000000000..b2439727d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -0,0 +1,390 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.insertUrlAtCursor +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96Uploader +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.relays.Relay +import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.FileStorageEvent +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +@Stable +open class EditPostViewModel() : ViewModel() { + var accountViewModel: AccountViewModel? = null + var account: Account? = null + + var editedFromNote: Note? = null + + var subject by mutableStateOf(TextFieldValue("")) + + var nip94attachments by mutableStateOf>(emptyList()) + var nip95attachments by + mutableStateOf>>(emptyList()) + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + // Images and Videos + var contentToAddUrl by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + open fun prepare( + edit: Note, + versionLookingAt: Note?, + accountViewModel: AccountViewModel, + ) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + this.editedFromNote = edit + } + + open fun load( + edit: Note, + versionLookingAt: Note?, + accountViewModel: AccountViewModel, + ) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + contentToAddUrl = null + + message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "") + urlPreview = findUrlInMessage() + + editedFromNote = edit + } + + fun sendPost(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + } + + suspend fun innerSendPost(relayList: List? = null) { + if (accountViewModel == null) { + cancel() + return + } + + nip95attachments.forEach { + account?.sendNip95(it.first, it.second, relayList) + } + + val notify = + if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) { + null + } else { + // notifies if it is not the logged in user + editedFromNote?.author?.pubkeyHex + } + + account?.sendEdit( + message = message.text, + originalNote = editedFromNote!!, + notify = notify, + summary = subject.text.ifBlank { null }, + relayList = relayList, + ) + + cancel() + } + + open fun updateSubject(it: TextFieldValue) { + subject = it + } + + fun upload( + galleryUri: Uri, + alt: String?, + sensitiveContent: Boolean, + isPrivate: Boolean = false, + server: ServerOption, + context: Context, + ) { + isUploadingImage = true + contentToAddUrl = null + + val contentResolver = context.contentResolver + val contentType = contentResolver.getType(galleryUri) + + viewModelScope.launch(Dispatchers.IO) { + MediaCompressor() + .compress( + galleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (server.isNip95) { + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) + } + } else { + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = server.server, + contentResolver = contentResolver, + onProgress = {}, + ) + + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "ImageUploader", + "Failed to upload ${e.message}", + e, + ) + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") + } + } + } + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } + } + + open fun cancel() { + message = TextFieldValue("") + subject = TextFieldValue("") + + editedFromNote = null + + contentToAddUrl = null + urlPreview = null + isUploadingImage = false + + wantsInvoice = false + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() + } + + open fun findUrlInMessage(): String? { + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) + } + } + } + + open fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = + message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = + TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } + + userSuggestionAnchor = null + userSuggestionsMainMessage = null + userSuggestions = emptyList() + } + } + + fun canPost(): Boolean { + return message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + contentToAddUrl == null + } + + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + // Images don't seem to be ready immediately after upload + val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + return + } + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> + isUploadingImage = false + nip94attachments = nip94attachments + event + + message = message.insertUrlAtCursor(imageUrl) + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) + } + + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + imageUploadingError.emit("Media is too big for NIP-95") + isUploadingImage = false + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + isUploadingImage = false + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 8406c09bd..d6bf6f367 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -142,7 +142,10 @@ fun JoinUserOrChannelView( ) { Surface { Column( - modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), + modifier = + Modifier + .padding(10.dp) + .heightIn(min = 500.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -267,7 +270,10 @@ private fun SearchEditTextForJoin( } Row( - modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + modifier = + Modifier + .padding(horizontal = 10.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -280,7 +286,8 @@ private fun SearchEditTextForJoin( }, leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, modifier = - Modifier.weight(1f, true) + Modifier + .weight(1f, true) .defaultMinSize(minHeight = 20.dp) .focusRequester(focusRequester) .onFocusChanged { @@ -330,7 +337,11 @@ private fun RenderSearchResults( } Row( - modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(vertical = 10.dp), ) { LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -411,7 +422,10 @@ fun UserComposeForChat( ClickableUserPicture(baseUser, Size55dp, accountViewModel) Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), ) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } @@ -419,7 +433,7 @@ fun UserComposeForChat( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index dcb217e38..f68a9fd3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -88,7 +88,7 @@ fun NewMediaView( val resolver = LocalContext.current.contentResolver val context = LocalContext.current - val scroolState = rememberScrollState() + val scrollState = rememberScrollState() LaunchedEffect(uri) { val mediaType = resolver.getType(uri) ?: "" @@ -173,7 +173,7 @@ fun NewMediaView( modifier = Modifier.fillMaxWidth().weight(1f), ) { Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scroolState), + modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), ) { ImageVideoPost(postViewModel, accountViewModel) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index ee8aeb49e..4ebf5c1be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -56,7 +56,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.LocationOff @@ -65,13 +66,12 @@ import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.filled.ShowChart import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -124,7 +124,6 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import com.fonfon.kgeohash.toGeoHash import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -136,7 +135,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.InvoiceRequest @@ -146,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.CancelIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon +import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.PollIcon import com.vitorpamplona.amethyst.ui.note.RegularPostIcon @@ -157,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.QuoteBorder @@ -189,6 +189,7 @@ fun NewPostView( baseReplyTo: Note? = null, quote: Note? = null, fork: Note? = null, + version: Note? = null, enableMessageInterface: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -207,13 +208,13 @@ fun NewPostView( launch(Dispatchers.IO) { val replyDraft = LocalPreferences.loadReplyDraft(accountViewModel.account) if (replyDraft.isNullOrBlank()) { - postViewModel.load(accountViewModel, baseReplyTo, quote, fork) + postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version) } else { val note = LocalCache.checkGetOrCreateNote(replyDraft) if (note == null) { - postViewModel.load(accountViewModel, baseReplyTo, quote, fork) + postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version) } else { - postViewModel.load(accountViewModel, note, quote, fork) + postViewModel.load(accountViewModel, note, quote, fork, version) } } @@ -488,34 +489,32 @@ fun NewPostView( } } - val user = postViewModel.account?.userProfile() - val lud16 = user?.info?.lnAddress() - - if (lud16 != null && postViewModel.wantsInvoice) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - Column(Modifier.fillMaxWidth()) { - InvoiceRequest( - lud16, - user.pubkeyHex, - accountViewModel.account, - stringResource(id = R.string.lightning_invoice), - stringResource(id = R.string.lightning_create_and_add_invoice), - onSuccess = { - postViewModel.message = - TextFieldValue(postViewModel.message.text + "\n\n" + it) - postViewModel.wantsInvoice = false - }, - onClose = { postViewModel.wantsInvoice = false }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onClose = { postViewModel.wantsInvoice = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } } } } - if (lud16 != null && postViewModel.wantsZapraiser) { + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), @@ -582,7 +581,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { } } - if (postViewModel.canAddInvoice) { + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { AddLnInvoiceButton(postViewModel.wantsInvoice) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } @@ -739,7 +738,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.add_sensitive_content_explainer), @@ -786,7 +785,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -820,7 +819,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) } } @@ -861,7 +860,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -904,7 +903,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -969,7 +968,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -1033,7 +1032,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -1067,7 +1066,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) } } @@ -1101,7 +1100,7 @@ fun FowardZapTo( tint = BitcoinOrange, ) Icon( - imageVector = Icons.Outlined.ArrowForwardIos, + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, contentDescription = stringResource(id = R.string.zaps), modifier = Modifier @@ -1119,7 +1118,7 @@ fun FowardZapTo( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.zap_split_explainer), @@ -1184,23 +1183,12 @@ fun FowardZapTo( @OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationAsHash(postViewModel: NewPostViewModel) { - val context = LocalContext.current - val locationPermissionState = rememberPermissionState( Manifest.permission.ACCESS_COARSE_LOCATION, ) if (locationPermissionState.status.isGranted) { - var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } - - DisposableEffect(key1 = Unit) { - postViewModel.startLocation(context = context) - locationDescriptionFlow = postViewModel.location - - onDispose { postViewModel.stopLocation() } - } - Column( modifier = Modifier.fillMaxWidth(), ) { @@ -1231,10 +1219,10 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { modifier = Modifier.padding(start = 10.dp), ) - locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) } + DisplayLocationObserver(postViewModel) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.geohash_explainer), @@ -1248,41 +1236,39 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { } @Composable -fun DisplayLocationObserver(geoLocation: Flow) { - val location by geoLocation.collectAsStateWithLifecycle(null) +fun DisplayLocationObserver(postViewModel: NewPostViewModel) { + val context = LocalContext.current + var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } - location?.let { DisplayLocationInTitle(geohash = it) } + DisposableEffect(key1 = context) { + postViewModel.startLocation(context = context) + locationDescriptionFlow = postViewModel.location + + onDispose { postViewModel.stopLocation() } + } + + locationDescriptionFlow?.let { + val location by it.collectAsStateWithLifecycle(null) + + location?.let { DisplayLocationInTitle(geohash = it) } + } } @Composable fun DisplayLocationInTitle(geohash: String) { - val context = LocalContext.current - - var cityName by remember(geohash) { mutableStateOf(geohash) } - - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { - null - } - - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } - } - } - - if (geohash != "s0000") { + LoadCityName( + geohashStr = geohash, + onLoading = { + Spacer(modifier = StdHorzSpacer) + LoadingAnimation() + }, + ) { cityName -> Text( text = cityName, fontSize = 20.sp, fontWeight = FontWeight.W500, modifier = Modifier.padding(start = Size5dp), ) - } else { - Spacer(modifier = StdHorzSpacer) - LoadingAnimation() } } @@ -1478,7 +1464,7 @@ private fun ForwardZapTo( tint = MaterialTheme.colorScheme.onBackground, ) Icon( - imageVector = Icons.Default.ArrowForwardIos, + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, contentDescription = null, modifier = Modifier @@ -1497,7 +1483,7 @@ private fun ForwardZapTo( tint = BitcoinOrange, ) Icon( - imageVector = Icons.Outlined.ArrowForwardIos, + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, contentDescription = null, modifier = Modifier @@ -1561,7 +1547,7 @@ private fun MarkAsSensitive( ) Icon( imageVector = Icons.Rounded.Warning, - contentDescription = null, + contentDescription = stringResource(R.string.add_content_warning), modifier = Modifier .size(10.dp) @@ -1580,7 +1566,7 @@ private fun MarkAsSensitive( ) Icon( imageVector = Icons.Rounded.Warning, - contentDescription = null, + contentDescription = stringResource(id = R.string.remove_content_warning), modifier = Modifier .size(10.dp) @@ -1749,7 +1735,7 @@ fun ImageVideoDescription( } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 19920809a..285a4139c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -164,11 +165,24 @@ open class NewPostViewModel() : ViewModel() { // NIP24 Wrapped DMs / Group messages var nip24 by mutableStateOf(false) + fun lnAddress(): String? { + return account?.userProfile()?.info?.lnAddress() + } + + fun hasLnAddress(): Boolean { + return account?.userProfile()?.info?.lnAddress() != null + } + + fun user(): User? { + return account?.userProfile() + } + open fun load( accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?, fork: Note?, + version: Note?, ) { this.accountViewModel = accountViewModel this.account = accountViewModel.account @@ -240,7 +254,7 @@ open class NewPostViewModel() : ViewModel() { } fork?.let { - message = TextFieldValue(it.event?.content() ?: "") + message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "") urlPreview = findUrlInMessage() it.event?.isSensitive()?.let { @@ -275,12 +289,16 @@ open class NewPostViewModel() : ViewModel() { } it.author?.let { - if (this.pTags?.contains(it) != true) { + if (this.pTags == null) { + this.pTags = listOf(it) + } else if (this.pTags?.contains(it) != true) { this.pTags = listOf(it) + (this.pTags ?: emptyList()) } } forkedFromNote = it + } ?: run { + forkedFromNote = null } if (!forwardZapTo.items.isEmpty()) { @@ -427,6 +445,45 @@ open class NewPostViewModel() : ViewModel() { nip94attachments = usedAttachments, ) } + } else if (originalNote?.event is GitIssueEvent) { + val originalNoteEvent = originalNote?.event as GitIssueEvent + // adds markers + val rootId = + originalNoteEvent.rootIssueOrPatch() // if it has a marker as root + ?: originalNote + ?.replyTo + ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } + ?.idHex // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. + ?: originalNote?.idHex + + val replyId = originalNote?.idHex + + val replyToSet = + if (forkedFromNote != null) { + (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } + } else { + tagger.eTags + } + + val repositoryAddress = originalNoteEvent.repository() + + account?.sendGitReply( + message = tagger.message, + replyTo = replyToSet, + mentions = tagger.pTags, + repository = repositoryAddress, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = replyId, + root = rootId, + directMentions = tagger.directMentions, + forkedFrom = forkedFromNote?.event as? Event, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) } else { if (wantsPoll) { account?.sendPoll( @@ -580,6 +637,8 @@ open class NewPostViewModel() : ViewModel() { toUsers = TextFieldValue("") subject = TextFieldValue("") + forkedFromNote = null + contentToAddUrl = null urlPreview = null isUploadingImage = false @@ -854,6 +913,10 @@ open class NewPostViewModel() : ViewModel() { ) } + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + fun createNIP95Record( bytes: ByteArray, mimeType: String?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 2e809ae02..f1ae54813 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -45,8 +45,8 @@ import androidx.compose.material.icons.filled.SyncProblem import androidx.compose.material.icons.filled.Upload import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -272,9 +272,7 @@ fun ServerConfigHeader() { } } - Divider( - thickness = DividerThickness, - ) + HorizontalDivider(thickness = DividerThickness) } } @@ -459,9 +457,7 @@ fun ServerConfigClickableLine( } } - Divider( - thickness = DividerThickness, - ) + HorizontalDivider(thickness = DividerThickness) } } @@ -839,7 +835,7 @@ private fun FirstLine( ) { Icon( imageVector = Icons.Default.Cancel, - null, + contentDescription = stringResource(id = R.string.remove), modifier = Modifier.padding(start = 10.dp).size(15.dp), tint = WarningColor, ) @@ -875,7 +871,7 @@ fun EditableServerConfig( IconButton(onClick = { read = !read }) { Icon( imageVector = Icons.Default.Download, - null, + contentDescription = stringResource(id = R.string.read_from_relay), modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), tint = if (read) { @@ -889,7 +885,7 @@ fun EditableServerConfig( IconButton(onClick = { write = !write }) { Icon( imageVector = Icons.Default.Upload, - null, + contentDescription = stringResource(id = R.string.write_to_relay), modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), tint = if (write) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt index c19d7531c..672e7acb8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt @@ -68,6 +68,7 @@ fun NotifyRequestDialog( Modifier.fillMaxWidth(), EmptyTagList, background, + textContent, accountViewModel, nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index c77dd6782..739a470fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -35,16 +35,16 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card -import androidx.compose.material3.Divider import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -66,7 +66,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.fasterxml.jackson.databind.node.TextNode import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ThemeType -import com.vitorpamplona.amethyst.service.CashuProcessor +import com.vitorpamplona.amethyst.service.CachedCashuProcessor import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.CashuIcon @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size18Modifier @@ -85,25 +86,26 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.subtleBorder import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun CashuPreview( cashutoken: String, accountViewModel: AccountViewModel, ) { - var cachuData by remember { - mutableStateOf>(GenericLoadable.Loading()) - } - - LaunchedEffect(key1 = cashutoken) { - launch(Dispatchers.IO) { - val newCachuData = CashuProcessor().parse(cashutoken) - launch(Dispatchers.Main) { cachuData = newCachuData } + val cashuData by produceState( + initialValue = CachedCashuProcessor.cached(cashutoken), + key1 = cashutoken, + ) { + withContext(Dispatchers.IO) { + val newToken = CachedCashuProcessor.parse(cashutoken) + if (value != newToken) { + value = newToken + } } } - Crossfade(targetState = cachuData, label = "CashuPreview(") { + Crossfade(targetState = cashuData, label = "CashuPreview") { when (it) { is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) is GenericLoadable.Error -> @@ -160,17 +162,24 @@ fun CashuPreview( Column( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) .clip(shape = QuoteBorder) .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), ) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Icon( painter = painterResource(R.drawable.cashu), @@ -187,17 +196,23 @@ fun CashuPreview( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", fontSize = 25.sp, fontWeight = FontWeight.W500, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), ) Row( - modifier = Modifier.padding(top = 5.dp).fillMaxWidth(), + modifier = + Modifier + .padding(top = 5.dp) + .fillMaxWidth(), ) { var isRedeeming by remember { mutableStateOf(false) } @@ -289,13 +304,17 @@ fun CashuPreviewNew( Card( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) .clip(shape = QuoteBorder), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -317,6 +336,7 @@ fun CashuPreviewNew( Text( text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", fontSize = 20.sp, + modifier = Modifier.padding(top = 5.dp), ) Row(modifier = Modifier.padding(top = 5.dp)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 477e7ac2b..cc784e243 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.util.LruCache import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,6 +52,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.quartz.events.ImmutableListOfLists +object ShowFullTextCache { + val cache = LruCache(20) +} + @Composable fun ExpandableRichTextViewer( content: String, @@ -58,10 +63,19 @@ fun ExpandableRichTextViewer( modifier: Modifier, tags: ImmutableListOfLists, backgroundColor: MutableState, + id: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - var showFullText by remember { mutableStateOf(false) } + var showFullText by remember { + val cached = ShowFullTextCache.cache[id] + if (cached == null) { + ShowFullTextCache.cache.put(id, false) + mutableStateOf(false) + } else { + mutableStateOf(cached) + } + } val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) } @@ -96,7 +110,10 @@ fun ExpandableRichTextViewer( .fillMaxWidth() .background(getGradient(backgroundColor)), ) { - ShowMoreButton { showFullText = !showFullText } + ShowMoreButton { + showFullText = !showFullText + ShowFullTextCache.cache.put(id, showFullText) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 497f70f51..1b2a8efd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -28,16 +28,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,43 +51,31 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser +import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import com.vitorpamplona.quartz.encoders.LnInvoiceUtil -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.text.NumberFormat - -@Stable data class InvoiceAmount(val invoice: String, val amount: String?) +import kotlinx.coroutines.withContext @Composable fun LoadValueFromInvoice( lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit, ) { - var lnInvoice by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = lnbcWord) { - launch(Dispatchers.IO) { - val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) - if (myInvoice != null) { - val myInvoiceAmount = - try { - NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) - } catch (e: Exception) { - if (e is CancellationException) throw e - e.printStackTrace() - null - } - - lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) + val lnInvoice by + produceState(initialValue = CachedLnInvoiceParser.cached(lnbcWord), key1 = lnbcWord) { + withContext(Dispatchers.IO) { + val newLnInvoice = CachedLnInvoiceParser.parse(lnbcWord) + if (value != newLnInvoice) { + value = newLnInvoice + } } } - } inner(lnInvoice) } @@ -128,17 +115,24 @@ fun InvoicePreview( Column( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) .clip(shape = QuoteBorder) .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), ) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Icon( painter = painterResource(R.drawable.lightning), @@ -155,19 +149,25 @@ fun InvoicePreview( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) amount?.let { Text( text = "$it ${stringResource(id = R.string.sats)}", fontSize = 25.sp, fontWeight = FontWeight.W500, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), ) } Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } }, shape = QuoteBorder, colors = diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 12a91502a..22b913d43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -131,7 +132,7 @@ fun InvoiceRequest( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) var message by remember { mutableStateOf("") } var amount by remember { mutableStateOf(1000L) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 2d71b7079..ca0b2cb5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -24,9 +24,8 @@ import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.commons.MediaUrlImage @@ -46,19 +45,16 @@ fun LoadUrlPreview( if (!automaticallyShowUrlPreview) { ClickableUrl(urlText, url) } else { - var urlPreviewState by - remember(url) { - mutableStateOf( - UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, - ) + val urlPreviewState by + produceState( + initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, + key1 = url, + ) { + if (value == UrlPreviewState.Loading) { + accountViewModel.urlPreview(url) { value = it } + } } - // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are - // created). - if (urlPreviewState == UrlPreviewState.Loading) { - LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } } - } - Crossfade( targetState = urlPreviewState, animationSpec = tween(durationMillis = 100), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index e6d8a1fef..97f90b808 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -344,6 +344,17 @@ private fun RenderRegular( } } } + + /* + // UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text. + val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull() + if (lastElement !is ImageSegment && + lastElement !is LinkSegment && + lastElement !is InvoiceSegment && + lastElement !is CashuSegment + ) { + Spacer(modifier = StdVertSpacer) + }*/ } } @@ -486,7 +497,11 @@ private fun RenderContentAsMarkdown( ZoomableContentView( content = remember(destination, tags) { - RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: MediaUrlImage(url = destination) + RichTextParser().parseMediaUrl( + destination, + tags ?: EmptyTagList, + title.ifEmpty { null } ?: content, + ) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content) }, roundedCorner = true, accountViewModel = accountViewModel, @@ -647,7 +662,7 @@ fun BechLink( accountViewModel, backgroundColor, nav, - loadedLink!!, + loadedLink?.nip19?.additionalChars?.ifBlank { null }, ) } } else if (loadedLink?.nip19 != null) { @@ -672,7 +687,7 @@ private fun DisplayFullNote( accountViewModel: AccountViewModel, backgroundColor: MutableState, nav: (String) -> Unit, - loadedLink: LoadedBechLink, + extraChars: String?, ) { NoteCompose( baseNote = it, @@ -683,8 +698,6 @@ private fun DisplayFullNote( nav = nav, ) - val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } } - extraChars?.let { Text( it, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt index afda98789..52b3844c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24dp @Composable @@ -75,7 +76,7 @@ fun SelectTextDialog( } Text(text = stringResource(R.string.select_text_dialog_top)) } - Divider() + HorizontalDivider(thickness = DividerThickness) Column( modifier = Modifier.verticalScroll(rememberScrollState()), ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 8c2141b05..aceca0ecf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -182,7 +182,7 @@ fun SpinnerSelectionDialog( fontWeight = FontWeight.Bold, ) } - Divider(color = Color.LightGray, thickness = DividerThickness) + HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } itemsIndexed(options) { index, item -> @@ -192,7 +192,7 @@ fun SpinnerSelectionDialog( Column { onRenderItem(item) } } if (index < options.lastIndex) { - Divider(color = Color.LightGray, thickness = DividerThickness) + HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 40001604b..3d7e00abc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -282,7 +282,7 @@ fun VideoView( } @Composable -@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoViewInner( videoUri: String, defaultToStart: Boolean = false, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 428e1086f..8d59c824c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -72,7 +73,7 @@ fun ZapRaiserRequest( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.zapraiser_explainer), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index a92a0d4a5..5e49b616e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.MuteListEvent @@ -94,13 +97,23 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() ): Boolean { val event = note.event + if (event is GitIssueEvent || event is GitPatchEvent) { + return true + } + + if (event is HighlightEvent) { + return true + } + if (event is BaseTextNoteEvent) { if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } val isAuthorDirectlyCited = event.citedUsers().contains(authorHex) + val isAuthorOfAFork = + event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true) - return isAuthoredPostCited || isAuthorDirectlyCited + return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork } if (event is ReactionEvent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index d40a92afe..ec810f28f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,7 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NewItemsBubble -import com.vitorpamplona.amethyst.ui.note.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -73,7 +73,7 @@ fun ChannelNamePreview() { onClick = {}, ) - Divider() + HorizontalDivider(thickness = DividerThickness) ListItem( headlineContent = { @@ -135,7 +135,7 @@ fun ChatHeaderLayout( } } - Divider( + HorizontalDivider( modifier = StdTopPadding, thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 102a143eb..008800784 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -132,7 +132,7 @@ private fun RenderBottomMenu( nav: (Route, Boolean) -> Unit, ) { Column(modifier = BottomTopHeight) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) NavigationBar(tonalElevation = Size0dp) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index c5dfcf639..e661a20e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -39,9 +39,9 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -74,7 +74,6 @@ import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.navigation.NavBackStackEntry import coil.Coil -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote @@ -108,12 +107,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.note.LoadUser -import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader +import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader @@ -395,8 +394,6 @@ private fun ChannelTopBar( } } -@Composable fun NoTopBar() {} - @Composable fun StoriesTopBar( followLists: FollowListViewModel, @@ -404,7 +401,7 @@ fun StoriesTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() FollowListWithRoutes( @@ -423,7 +420,7 @@ fun HomeTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() FollowListWithRoutes( @@ -446,7 +443,7 @@ fun NotificationTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() FollowListWithoutRoutes( @@ -465,7 +462,7 @@ fun DiscoveryTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() FollowListWithoutRoutes( @@ -492,7 +489,7 @@ fun GenericMainTopBar( drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit, - content: @Composable (AccountViewModel) -> Unit, + content: @Composable () -> Unit, ) { Column(modifier = BottomTopHeight) { TopAppBar( @@ -511,7 +508,7 @@ fun GenericMainTopBar( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - content(accountViewModel) + content() } } } @@ -522,7 +519,7 @@ fun GenericMainTopBar( }, actions = { SearchButton { nav(Route.Search.route) } }, ) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -817,15 +814,12 @@ fun SimpleTextSpinner( fun RenderOption(option: Name) { when (option) { is GeoHashName -> { - val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull() - if (geohash != null) { - LoadCityName(geohash) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) - } + LoadCityName(option.geoHashTag) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) } } } @@ -893,7 +887,7 @@ fun TopBarWithBackButton( }, actions = {}, ) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -911,7 +905,7 @@ fun FlexibleTopBarWithBackButton( actions = {}, ) Spacer(modifier = HalfVertSpacer) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -1068,8 +1062,6 @@ fun MyExtensibleTopAppBar( } } -private val AppBarHeight = 50.dp - // TODO: this should probably be part of the touch target of the start and end icons, clarify this private val AppBarHorizontalPadding = 4.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index d61df96f6..3a352a372 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -48,8 +48,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Send import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerState +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -78,12 +78,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.actions.PostButton +import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.LoadStatuses @@ -163,7 +167,7 @@ fun DrawerContent( FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) - Divider( + HorizontalDivider( thickness = DividerThickness, modifier = Modifier.padding(top = 20.dp), ) @@ -872,7 +876,7 @@ fun BottomContent( var dialogOpen by remember { mutableStateOf(false) } Column(modifier = Modifier) { - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 15.dp), thickness = DividerThickness, ) @@ -883,29 +887,24 @@ fun BottomContent( .padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( + ClickableText( + text = + buildAnnotatedString { + withStyle( + SpanStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) { + append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) + } + }, + onClick = { + nav("Note/${BuildConfig.RELEASE_NOTES_ID}") + coroutineScope.launch { drawerState.close() } + }, modifier = Modifier.padding(start = 16.dp), - text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, ) - /* - IconButton( - onClick = { - when (AppCompatDelegate.getDefaultNightMode()) { - AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - } - ) { - Icon( - painter = painterResource(R.drawable.ic_theme), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - }*/ Box(modifier = Modifier.weight(1F)) IconButton( onClick = { @@ -915,7 +914,7 @@ fun BottomContent( ) { Icon( painter = painterResource(R.drawable.ic_qrcode), - null, + contentDescription = stringResource(id = R.string.show_npub_as_a_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index ecdc18bf2..8ad6a0d5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.navigation import android.os.Bundle import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -32,7 +31,6 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination import androidx.navigation.NavHostController import androidx.navigation.NavType -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navArgument import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -240,16 +238,6 @@ sealed class Route( } } -// ** -// * Functions below only exist because we have not broken the datasource classes into backend and -// frontend. -// ** -@Composable -fun currentRoute(navController: NavHostController): String? { - val navBackStackEntry by navController.currentBackStackEntryAsState() - return navBackStackEntry?.destination?.route -} - open class LatestItem { var newestItemPerAccount: Map = mapOf() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 007f5ea66..f85d8fbaf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MilitaryTech import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.BadgeCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -83,7 +84,7 @@ fun BadgeCompose( val scope = rememberCoroutineScope() if (note == null) { - BlankNote(Modifier, isInnerNote) + BlankNote(Modifier, !isInnerNote) } else { val defaultBackgroundColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } @@ -162,12 +163,12 @@ fun BadgeCompose( ) { Icon( imageVector = Icons.Default.MoreVert, - null, + contentDescription = stringResource(id = R.string.more_options), modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) - NoteDropDownMenu(note, popupExpanded, accountViewModel) + NoteDropDownMenu(note, popupExpanded, null, accountViewModel, nav) } } @@ -183,7 +184,7 @@ fun BadgeCompose( ) } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 81823b0c9..878dec216 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,20 +36,34 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +@Composable +@Preview +fun BlankNotePreview() { + ThemeComparisonColumn( + onDark = { BlankNote() }, + onLight = { BlankNote() }, + ) +} @Composable fun BlankNote( modifier: Modifier = Modifier, - showDivider: Boolean = false, + showDivider: Boolean = true, idHex: String? = null, ) { Column(modifier = modifier) { @@ -62,7 +75,7 @@ fun BlankNote( start = 20.dp, end = 20.dp, bottom = 8.dp, - top = 15.dp, + top = 8.dp, ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -71,10 +84,11 @@ fun BlankNote( text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", modifier = Modifier.padding(30.dp), color = Color.Gray, + textAlign = TextAlign.Center, ) } - if (!showDivider) { + if (showDivider) { HorizontalDivider( modifier = Modifier.padding(vertical = 10.dp), thickness = DividerThickness, @@ -85,6 +99,32 @@ fun BlankNote( } } +@Composable +@Preview +fun HiddenNotePreview() { + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + ThemeComparisonColumn( + onDark = { + HiddenNote( + reports = persistentSetOf(), + isHiddenAuthor = true, + accountViewModel = accountViewModel, + nav = nav, + ) {} + }, + onLight = { + HiddenNote( + reports = persistentSetOf(), + isHiddenAuthor = true, + accountViewModel = accountViewModel, + nav = nav, + ) {} + }, + ) +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun HiddenNote( @@ -144,8 +184,61 @@ fun HiddenNote( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } } + +@Preview +@Composable +fun HiddenNoteByMePreview() { + ThemeComparisonColumn( + onDark = { HiddenNoteByMe {} }, + onLight = { HiddenNoteByMe {} }, + ) +} + +@Composable +fun HiddenNoteByMe( + modifier: Modifier = Modifier, + isQuote: Boolean = false, + onClick: () -> Unit, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(30.dp), + ) { + Text( + text = stringResource(R.string.post_was_hidden), + color = Color.Gray, + ) + + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.show_anyway), color = Color.White) + } + } + } + + if (!isQuote) { + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index e1c59eede..7443a653f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -400,7 +400,7 @@ fun InnerCardRow( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 4d647c181..38ce86483 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -641,6 +641,7 @@ private fun RenderRegularTextNote( modifier = HalfTopPadding, tags = tags, backgroundColor = backgroundBubbleColor, + id = note.idHex, accountViewModel = accountViewModel, nav = nav, ) @@ -652,6 +653,7 @@ private fun RenderRegularTextNote( modifier = HalfTopPadding, tags = EmptyTagList, backgroundColor = backgroundBubbleColor, + id = note.idHex, accountViewModel = accountViewModel, nav = nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 1e0616bd3..4f714d59d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -22,7 +22,10 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear @@ -33,11 +36,8 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.VolumeOff -import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleButton fun AmethystIcon(iconSize: Dp) { Icon( painter = painterResource(R.drawable.amethyst), - null, + contentDescription = stringResource(id = R.string.app_logo), modifier = Modifier.size(iconSize), tint = Color.Unspecified, ) @@ -81,7 +81,7 @@ fun FollowingIcon(iconSize: Dp) { @Composable fun ArrowBackIcon() { Icon( - imageVector = Icons.Default.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), tint = MaterialTheme.colorScheme.grayText, ) @@ -104,7 +104,7 @@ fun DownloadForOfflineIcon( ) { Icon( imageVector = Icons.Default.DownloadForOffline, - null, + contentDescription = stringResource(id = R.string.accessibility_download_for_offline), modifier = remember(iconSize) { Modifier.size(iconSize) }, tint = tint, ) @@ -237,7 +237,7 @@ fun OpenInNewIcon( tint: Color = Color.Unspecified, ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, stringResource(id = R.string.copy_to_clipboard), tint = tint, modifier = modifier, @@ -320,7 +320,7 @@ fun RegularPostIcon() { fun CancelIcon() { Icon( imageVector = Icons.Default.Cancel, - null, + contentDescription = stringResource(id = R.string.cancel), modifier = Size30Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) @@ -338,7 +338,7 @@ fun CloseIcon() { @Composable fun MutedIcon() { Icon( - imageVector = Icons.Default.VolumeOff, + imageVector = Icons.AutoMirrored.Filled.VolumeOff, contentDescription = stringResource(id = R.string.muted_button), tint = MaterialTheme.colorScheme.onBackground, modifier = Size30Modifier, @@ -348,7 +348,7 @@ fun MutedIcon() { @Composable fun MuteIcon() { Icon( - imageVector = Icons.Default.VolumeUp, + imageVector = Icons.AutoMirrored.Filled.VolumeUp, contentDescription = stringResource(id = R.string.mute_button), tint = MaterialTheme.colorScheme.onBackground, modifier = Size30Modifier, @@ -375,7 +375,7 @@ fun PlayIcon( ) { Icon( imageVector = Icons.Outlined.PlayCircle, - contentDescription = null, + contentDescription = stringResource(id = R.string.accessibility_play_username), modifier = modifier, tint = tint, ) @@ -401,7 +401,7 @@ fun LyricsIcon( ) { Icon( painter = painterResource(id = R.drawable.lyrics_on), - contentDescription = null, + contentDescription = stringResource(id = R.string.accessibility_lyrics_on), modifier = modifier, tint = tint, ) @@ -414,7 +414,7 @@ fun LyricsOffIcon( ) { Icon( painter = painterResource(id = R.drawable.lyrics_off), - contentDescription = null, + contentDescription = stringResource(id = R.string.accessibility_lyrics_off), modifier = modifier, tint = tint, ) @@ -480,3 +480,29 @@ fun NIP05FailedVerification(modifier: Modifier) { tint = Color.Red, ) } + +@Composable +fun IncognitoIconOn( + modifier: Modifier, + tint: Color, +) { + Icon( + painter = painterResource(id = R.drawable.incognito), + contentDescription = stringResource(id = R.string.accessibility_turn_off_sealed_message), + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun IncognitoIconOff( + modifier: Modifier, + tint: Color, +) { + Icon( + painter = painterResource(id = R.drawable.incognito_off), + contentDescription = stringResource(id = R.string.accessibility_turn_on_sealed_message), + modifier = modifier, + tint = tint, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt new file mode 100644 index 000000000..4ec5f2eac --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -0,0 +1,243 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.fonfon.kgeohash.toGeoHash +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.CachedGeoLocations +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.ATag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun LoadDecryptedContent( + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String) -> Unit, +) { + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } + + decryptedContent?.let { inner(it) } + ?: run { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } + } +} + +@Composable +fun LoadDecryptedContentOrNull( + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String?) -> Unit, +) { + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } + + if (decryptedContent == null) { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } + } + + inner(decryptedContent) +} + +@Composable +fun LoadAddressableNote( + aTagHex: String, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTagHex) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) + } + + if (note == null) { + LaunchedEffect(key1 = aTagHex) { + accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) +} + +@Composable +fun LoadAddressableNote( + aTag: ATag, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTag) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) + } + + if (note == null) { + LaunchedEffect(key1 = aTag) { + accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) +} + +@Composable +fun LoadStatuses( + user: User, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, +) { + var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } + + val userStatus by user.live().statuses.observeAsState() + + LaunchedEffect(key1 = userStatus) { + accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } + } + } + + content(statuses) +} + +@Composable +fun LoadOts( + note: Note, + accountViewModel: AccountViewModel, + whenConfirmed: @Composable (Long) -> Unit, + whenPending: @Composable () -> Unit, +) { + var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } + + val noteStatus by note.live().innerOts.observeAsState() + + LaunchedEffect(key1 = noteStatus) { + accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> + earliestDate = + if (newOts == null) { + GenericLoadable.Empty() + } else { + GenericLoadable.Loaded(newOts) + } + } + } + + (earliestDate as? GenericLoadable.Loaded)?.let { + whenConfirmed(it.loaded) + } ?: run { + val account = accountViewModel.account.saveable.observeAsState() + if (account.value?.account?.hasPendingAttestations(note) == true) { + whenPending() + } + } +} + +@Composable +fun LoadCityName( + geohashStr: String, + onLoading: (@Composable () -> Unit)? = null, + content: @Composable (String) -> Unit, +) { + var cityName by remember(geohashStr) { mutableStateOf(CachedGeoLocations.cached(geohashStr)) } + + if (cityName == null) { + if (onLoading != null) { + onLoading() + } + + val context = LocalContext.current + + LaunchedEffect(key1 = geohashStr, context) { + launch(Dispatchers.IO) { + val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() + if (geoHash != null) { + val newCityName = + CachedGeoLocations.geoLocate(geohashStr, geoHash.toLocation(), context) + ?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } + } + } + } + } else { + cityName?.let { content(it) } + } +} + +@Composable +fun LoadChannel( + baseChannelHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Channel) -> Unit, +) { + var channel by + remember(baseChannelHex) { + mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + } + + if (channel == null) { + LaunchedEffect(key1 = baseChannelHex) { + accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> + launch(Dispatchers.Main) { channel = newChannel } + } + } + } + + channel?.let { content(it) } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 3e75719d9..e5beeccf9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -129,11 +130,11 @@ fun MessageSetCompose( nav = nav, ) - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav) } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 3226d05cc..b36e64c93 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -70,10 +70,12 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -158,7 +160,7 @@ fun MultiSetCompose( NoteCompose( baseNote = baseNote, routeForLastRead = null, - modifier = remember { Modifier.padding(top = 5.dp) }, + modifier = HalfTopPadding, isBoostedNote = true, showHidden = showHidden, parentBackgroundColor = backgroundColor, @@ -166,10 +168,10 @@ fun MultiSetCompose( nav = nav, ) - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav) } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -472,6 +474,7 @@ fun CrossfadeToDisplayComment( tags = EmptyTagList, modifier = textBoxModifier, backgroundColor = backgroundColor, + id = comment, accountViewModel = accountViewModel, nav = nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index fbab305e1..679cd84b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -20,43 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.graphics.Bitmap -import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CutCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -69,116 +45,81 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.get import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import coil.request.SuccessResult -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fonfon.kgeohash.GeoHash -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.BaseMediaContent -import com.vitorpamplona.amethyst.commons.MediaLocalImage -import com.vitorpamplona.amethyst.commons.MediaLocalVideo -import com.vitorpamplona.amethyst.commons.MediaUrlImage -import com.vitorpamplona.amethyst.commons.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.RelayBriefInfoCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil -import com.vitorpamplona.amethyst.ui.actions.NewRelayListView -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.VideoView -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.elements.AddButton -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayPoW -import com.vitorpamplona.amethyst.ui.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits -import com.vitorpamplona.amethyst.ui.elements.RemoveButton -import com.vitorpamplona.amethyst.ui.elements.Reward import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark +import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation +import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts +import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW +import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.Reward +import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.CommunityHeader +import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack +import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource +import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent +import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderReaction +import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent +import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size34dp -import com.vitorpamplona.amethyst.ui.theme.Size35Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight @@ -186,19 +127,11 @@ import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor -import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.normalNoteModifier import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyBackground import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import com.vitorpamplona.quartz.encoders.ATag -import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent @@ -212,24 +145,17 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent -import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.events.EmojiUrl -import com.vitorpamplona.quartz.events.EmptyTagList -import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.quartz.events.LongTextNoteEvent -import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -239,25 +165,14 @@ import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent -import com.vitorpamplona.quartz.events.UserMetadata -import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent -import com.vitorpamplona.quartz.events.toImmutableListOfLists -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.net.URL -import java.text.DecimalFormat -import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable @@ -294,8 +209,7 @@ fun NoteCompose( nav = nav, ) } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, - -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> BlankNote( remember { modifier.combinedClickable( @@ -303,7 +217,7 @@ fun NoteCompose( onLongClick = showPopup, ) }, - isBoostedNote || isQuotedNote, + !isBoostedNote && !isQuotedNote, ) } } @@ -357,8 +271,13 @@ fun CheckHiddenNoteCompose( } .observeAsState(accountViewModel.isNoteHidden(note)) + val showAnyway = + remember { + mutableStateOf(false) + } + Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") { - if (!it) { + if (!it || showAnyway.value) { LoadedNoteCompose( note = note, routeForLastRead = routeForLastRead, @@ -372,6 +291,11 @@ fun CheckHiddenNoteCompose( accountViewModel = accountViewModel, nav = nav, ) + } else if (isQuotedNote || isBoostedNote) { + HiddenNoteByMe( + isQuote = true, + onClick = { showAnyway.value = true }, + ) } } } @@ -439,8 +363,7 @@ fun RenderReportState( ) { var showReportedNote by remember(note) { mutableStateOf(false) } - Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { - showHiddenNote -> + Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { showHiddenNote -> if (showHiddenNote) { HiddenNote( state.relevantReports, @@ -597,313 +520,6 @@ fun NormalNote( } } -@Composable -fun CommunityHeader( - baseNote: AddressableNote, - showBottomDiviser: Boolean, - sendToCommunity: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val expanded = remember { mutableStateOf(false) } - - Column(Modifier.fillMaxWidth()) { - Column( - verticalArrangement = Arrangement.Center, - modifier = - Modifier.clickable { - if (sendToCommunity) { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } - } else { - expanded.value = !expanded.value - } - }, - ) { - ShortCommunityHeader( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav, - ) - - if (expanded.value) { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader( - baseNote = baseNote, - lineModifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (showBottomDiviser) { - Divider( - thickness = DividerThickness, - ) - } - } -} - -@Composable -fun LongCommunityHeader( - baseNote: AddressableNote, - lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - - Row( - lineModifier, - ) { - val rulesLabel = stringResource(id = R.string.rules) - val summary = - remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } - - if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { - if (rules == null) { - "### $subject\n$body" - } else { - "### $subject\n$body\n\n### $rulesLabel\n\n$rules" - } - } else { - if (rules == null) { - body - } else { - "$body\n\n$rulesLabel\n$rules" - } - } - } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.community_no_descriptor), - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) - } - } - - Column { - Row { - Spacer(DoubleHorzSpacer) - LongCommunityActionOptions(baseNote, accountViewModel, nav) - } - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) - } - - var participantUsers by - remember(baseNote) { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() - - if (participants != null) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) - ) { - participantUsers = newParticipantUsers - } - } - } - } - - participantUsers.forEach { - Row( - lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, - verticalAlignment = CenterVertically, - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) - MoreOptionsButton(baseNote, accountViewModel) - } -} - -@Composable -fun ShortCommunityHeader( - baseNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - - val automaticallyShowProfilePicture = - remember { - accountViewModel.settings.showProfilePictures.value - } - - Row(verticalAlignment = CenterVertically) { - noteEvent.image()?.let { - RobohashFallbackAsyncImage( - robot = baseNote.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture, - ) - } - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .height(Size35dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteState) { noteEvent.dTag() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = - Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = CenterVertically, - ) { - ShortCommunityActionOptions(baseNote, accountViewModel, nav) - } - } -} - -@Composable -private fun ShortCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (!isFollowing) { - Spacer(modifier = StdHorzSpacer) - JoinCommunityButton(accountViewModel, note, nav) - } - } -} - -@Composable -fun WatchAddressableNoteFollows( - note: AddressableNote, - accountViewModel: AccountViewModel, - onFollowChanges: @Composable (Boolean) -> Unit, -) { - val showFollowingMark by - remember { - accountViewModel.userFollows - .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } - .distinctUntilChanged() - } - .observeAsState(false) - - onFollowChanges(showFollowingMark) -} - -@Composable -private fun LongCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveCommunityButton(accountViewModel, note, nav) - } - } -} - @Composable private fun CheckNewAndRenderNote( baseNote: Note, @@ -1028,6 +644,7 @@ fun InnerNoteWithReactions( nav: (String) -> Unit, ) { val notBoostedNorQuote = !isBoostedNote && !isQuotedNote + val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) Row( modifier = @@ -1060,6 +677,7 @@ fun InnerNoteWithReactions( canPreview = canPreview, showSecondRow = showSecondRow, backgroundColor = backgroundColor, + editState = editState, accountViewModel = accountViewModel, nav = nav, ) @@ -1078,6 +696,7 @@ fun InnerNoteWithReactions( ReactionsRow( baseNote = baseNote, showReactionDetail = notBoostedNorQuote, + editState = editState, accountViewModel = accountViewModel, nav = nav, ) @@ -1085,14 +704,14 @@ fun InnerNoteWithReactions( } if (notBoostedNorQuote) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } } @Composable -private fun NoteBody( +fun NoteBody( baseNote: Note, showAuthorPicture: Boolean = false, unPackReply: Boolean = true, @@ -1100,12 +719,14 @@ private fun NoteBody( canPreview: Boolean = true, showSecondRow: Boolean, backgroundColor: MutableState, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { FirstUserInfoRow( baseNote = baseNote, showAuthorPicture = showAuthorPicture, + editState = editState, accountViewModel = accountViewModel, nav = nav, ) @@ -1133,12 +754,13 @@ private fun NoteBody( } RenderNoteRow( - baseNote, - backgroundColor, - makeItShort, - canPreview, - accountViewModel, - nav, + baseNote = baseNote, + backgroundColor = backgroundColor, + makeItShort = makeItShort, + canPreview = canPreview, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, ) val noteEvent = baseNote.event @@ -1155,6 +777,7 @@ private fun RenderNoteRow( backgroundColor: MutableState, makeItShort: Boolean, canPreview: Boolean, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -1221,6 +844,16 @@ private fun RenderNoteRow( nav, ) } + is GitIssueEvent -> { + RenderGitIssueEvent( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } is PrivateDmEvent -> { RenderPrivateMessage( baseNote, @@ -1281,8 +914,8 @@ private fun RenderNoteRow( nav, ) } - else -> { - RenderTextEvent( + is TextNoteModificationEvent -> { + RenderTextModificationEvent( baseNote, makeItShort, canPreview, @@ -1291,690 +924,20 @@ private fun RenderNoteRow( nav, ) } - } -} - -@Composable -fun LoadDecryptedContent( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String) -> Unit, -) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) - } - - decryptedContent?.let { inner(it) } - ?: run { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } -} - -@Composable -fun LoadDecryptedContentOrNull( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String?) -> Unit, -) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) - } - - if (decryptedContent == null) { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } - - inner(decryptedContent) -} - -@Composable -fun RenderTextEvent( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } -} - -@Composable -fun RenderPoll( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = remember(note) { noteEvent.content() } - - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = remember { Modifier.fillMaxWidth() }, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - PollNote( - note, - canPreview = canPreview && !makeItShort, + else -> { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, backgroundColor, + editState, accountViewModel, nav, ) } - - if (noteEvent.hasHashtags()) { - val hashtags = remember { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RenderAppDefinition( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? AppDefinitionEvent ?: return - - var metadata by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = noteEvent) { - launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() } - } - - metadata?.let { - Box { - val clipboardManager = LocalClipboardManager.current - val uri = LocalUriHandler.current - - if (!it.banner.isNullOrBlank()) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - AsyncImage( - model = it.banner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth() - .height(125.dp) - .combinedClickable( - onClick = {}, - onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, - ), - ) - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth() - .height(125.dp), - ) - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(top = 75.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - Box(Modifier.size(100.dp)) { - it.picture?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .border( - 3.dp, - MaterialTheme.colorScheme.background, - CircleShape, - ) - .clip(shape = CircleShape) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, - ), - ) - } - } - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - - Spacer(Modifier.weight(1f)) - - Row( - modifier = - Modifier - .height(Size35dp) - .padding(bottom = 3.dp), - ) {} - } - - val name = remember(it) { it.anyName() } - name?.let { - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() }, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - } - } - - val website = remember(it) { it.website } - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = CenterVertically) { - LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { website.let { runCatching { uri.openUri(it) } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - ) - } - } - - it.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - val tags = - remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(bgColor) } - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } - } -} - -@Composable -private fun RenderHighlight( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } - val author = remember { (note.event as? HighlightEvent)?.author() } - val url = remember { (note.event as? HighlightEvent)?.inUrl() } - val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } - - DisplayHighlight( - highlight = quote, - authorHex = author, - url = url, - postAddress = postHex, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -private fun RenderPrivateMessage( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? PrivateDmEvent ?: return - - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } - if (withMe) { - LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } - val isAuthorTheLoggedUser = - remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } - - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (noteEvent.hasHashtags()) { - val hashtags = - remember(note.event?.id()) { - note.event?.hashtags()?.toImmutableList() ?: persistentListOf() - } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } - } else { - val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" - - TranslatableRichTextViewer( - stringResource( - id = R.string.private_conversation_notification, - "@${note.author?.pubkeyNpub()}", - "@$recipient", - ), - canPreview = !makeItShort, - Modifier.fillMaxWidth(), - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) - } -} - -@Composable -fun DisplayRelaySet( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? RelaySetEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), - ) - } - - var expanded by remember { mutableStateOf(false) } - - val toMembersShow = - if (expanded) { - relays - } else { - relays.take(3) - } - - val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - - val relayDescription by remember { derivedStateOf { noteEvent.description() } } - - Text( - text = relayListName, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - relayDescription?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - color = Color.Gray, - ) - } - - Box { - Column(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { relay -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - Text( - text = relay.displayUrl, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(start = 10.dp, bottom = 5.dp) - .weight(1f), - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) - } - } - } - } - - if (relays.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@Composable -private fun RelayOptionsAction( - relay: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() - val isCurrentlyOnTheUsersList by - remember(userStateRelayInfo) { - derivedStateOf { - userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true - } - } - - var wantsToAddRelay by remember { mutableStateOf("") } - - if (wantsToAddRelay.isNotEmpty()) { - NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) - } - - if (isCurrentlyOnTheUsersList) { - AddRelayButton { wantsToAddRelay = relay } - } else { - RemoveRelayButton { wantsToAddRelay = relay } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun DisplayPeopleList( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? PeopleListEvent ?: return - - var members by remember { mutableStateOf>(persistentListOf()) } - - var expanded by remember { mutableStateOf(false) } - - val toMembersShow = - if (expanded) { - members - } else { - members.take(3) - } - - val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } } - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Row(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - overallModifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RenderBadgeAward( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (note.replyTo.isNullOrEmpty()) return - - val noteEvent = note.event as? BadgeAwardEvent ?: return - var awardees by remember { mutableStateOf>(listOf()) } - - Text(text = stringResource(R.string.award_granted_to)) - - LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } - - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - awardees.take(100).forEach { user -> - Row( - modifier = - Modifier - .size(size = Size35dp) - .clickable { nav("User/${user.pubkeyHex}") }, - verticalAlignment = CenterVertically, - ) { - ClickableUserPicture( - baseUser = user, - accountViewModel = accountViewModel, - size = Size35dp, - ) - } - } - - if (awardees.size > 100) { - Text(" and ${awardees.size - 100} others", maxLines = 1) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = false, - isQuotedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -private fun RenderReaction( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - // Reposts have trash in their contents. - val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: "" - - Text( - text = refactorReactionText, - maxLines = 1, - ) -} - @Composable fun RenderRepost( note: Note, @@ -1997,328 +960,6 @@ fun RenderRepost( } } -@Composable -fun RenderPostApproval( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (note.replyTo.isNullOrEmpty()) return - - val noteEvent = note.event as? CommunityPostApprovalEvent ?: return - - Column(Modifier.fillMaxWidth()) { - noteEvent.communities().forEach { - LoadAddressableNote(it, accountViewModel) { - it?.let { - NoteCompose( - it, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - Text( - text = stringResource(id = R.string.community_approved_posts), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - note.replyTo?.forEach { - NoteCompose( - it, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - isQuotedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun LoadAddressableNote( - aTagHex: String, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) - } - - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - -@Composable -fun LoadAddressableNote( - aTag: ATag, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTag) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) - } - - if (note == null) { - LaunchedEffect(key1 = aTag) { - accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - -@Composable -public fun RenderEmojiPack( - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, -) { - val noteEvent by - baseNote - .live() - .metadata - .map { it.note.event } - .distinctUntilChanged() - .observeAsState(baseNote.event) - - if (noteEvent == null || noteEvent !is EmojiPackEvent) return - - (noteEvent as? EmojiPackEvent)?.let { - RenderEmojiPack( - noteEvent = it, - baseNote = baseNote, - actionable = actionable, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -public fun RenderEmojiPack( - noteEvent: EmojiPackEvent, - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, -) { - var expanded by remember { mutableStateOf(false) } - - val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } - - val emojisToShow = - if (expanded) { - allEmojis - } else { - allEmojis.take(60) - } - - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteEvent) { "#${noteEvent.dTag()}" }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .weight(1F) - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - if (actionable) { - EmojiListOptions(accountViewModel, baseNote) - } - } - - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - emojisToShow.forEach { emoji -> - if (onClick != null) { - IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } - } else { - Box( - modifier = Size35Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } - } - } - } - - if (allEmojis.size > 60 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@Composable -private fun EmojiListOptions( - accountViewModel: AccountViewModel, - emojiPackNote: Note, -) { - LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), - accountViewModel, - ) { - it?.let { usersEmojiList -> - val hasAddedThis by - remember { - usersEmojiList - .live() - .metadata - .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } - .distinctUntilChanged() - } - .observeAsState() - - Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { - if (it != true) { - AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } - } else { - RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } - } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun RenderPinListEvent( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? PinListEvent ?: return - - val pins by remember { mutableStateOf(noteEvent.pins()) } - - var expanded by remember { mutableStateOf(false) } - - val pinsToShow = - if (expanded) { - pins - } else { - pins.take(3) - } - - Text( - text = "#${noteEvent.dTag()}", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - pinsToShow.forEach { pin -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - PinIcon( - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), - ) - - Spacer(modifier = Modifier.width(5.dp)) - - TranslatableRichTextViewer( - content = pin, - canPreview = true, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (pins.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - fun getGradient(backgroundColor: MutableState): Brush { return Brush.verticalGradient( colors = @@ -2329,104 +970,6 @@ fun getGradient(backgroundColor: MutableState): Brush { ) } -@Composable -private fun RenderAudioTrack( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? AudioTrackEvent ?: return - - AudioTrackHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun RenderAudioHeader( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? AudioHeaderEvent ?: return - - AudioHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun RenderLongFormContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? LongTextNoteEvent ?: return - - LongFormHeader(noteEvent, note, accountViewModel) -} - -@Composable -private fun RenderReport( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? ReportEvent ?: return - - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } - - val reportType = - base - .map { - when (it.reportType) { - ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) - ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) - ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) - ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) - ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) - ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) - ReportEvent.ReportType.OTHER -> stringResource(R.string.other) - } - } - .toSet() - .joinToString(", ") - - val content = - remember { - reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") - } - - TranslatableRichTextViewer( - content = content, - canPreview = true, - modifier = Modifier, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - note.replyTo?.lastOrNull()?.let { - NoteCompose( - baseNote = it, - isQuotedNote = true, - modifier = - Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - unPackReply = false, - makeItShort = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - @Composable private fun ReplyRow( note: Note, @@ -2571,232 +1114,16 @@ fun SecondUserInfoRow( } } -@Composable -fun DisplayOts( - note: Note, - accountViewModel: AccountViewModel, -) { - LoadOts( - note, - accountViewModel, - whenConfirmed = { unixtimestamp -> - val context = LocalContext.current - val timeStr by remember(unixtimestamp) { mutableStateOf(timeAgoNoDot(unixtimestamp, context = context)) } - - ClickableText( - text = buildAnnotatedString { append(stringResource(id = R.string.existed_since, timeStr)) }, - onClick = { - val fullDateTime = - SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) - - accountViewModel.toast( - context.getString(R.string.ots_info_title), - context.getString(R.string.ots_info_description, fullDateTime), - ) - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - }, - whenPending = { - Text( - stringResource(id = R.string.timestamp_pending_short), - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - }, - ) -} - -@Composable -private fun ShowForkInformation( - noteEvent: BaseTextNoteEvent, - modifier: Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } - val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } - if (forkedAddress != null) { - LoadAddressableNote(aTag = forkedAddress, accountViewModel = accountViewModel) { addressableNote -> - if (addressableNote != null) { - ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) - } - } - } else if (forkedEvent != null) { - LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> - if (event != null) { - ForkInformationRowLightColor(event, modifier, accountViewModel, nav) - } - } - } -} - -@Composable -fun ForkInformationRowLightColor( - originalVersion: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by originalVersion.live().metadata.observeAsState() - val note = noteState?.note ?: return - val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } - - if (route != null) { - Row(modifier) { - ClickableText( - text = - buildAnnotatedString { - append(stringResource(id = R.string.forked_from)) - append(" ") - }, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), - maxLines = 1, - overflow = TextOverflow.Visible, - ) - - val userState by author.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - maxLines = 1, - route = route, - overrideColor = MaterialTheme.colorScheme.nip05, - fontSize = Font14SP, - nav = nav, - tags = userTags, - ) - } - } - } -} - -@Composable -fun LoadStatuses( - user: User, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, -) { - var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } - - val userStatus by user.live().statuses.observeAsState() - - LaunchedEffect(key1 = userStatus) { - accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> - if (!equalImmutableLists(statuses, newStatuses)) { - statuses = newStatuses - } - } - } - - content(statuses) -} - -@Composable -fun LoadOts( - note: Note, - accountViewModel: AccountViewModel, - whenConfirmed: @Composable (Long) -> Unit, - whenPending: @Composable () -> Unit, -) { - var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } - - val noteStatus by note.live().innerOts.observeAsState() - - LaunchedEffect(key1 = noteStatus) { - accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> - earliestDate = - if (newOts == null) { - GenericLoadable.Empty() - } else { - GenericLoadable.Loaded(newOts) - } - } - } - - (earliestDate as? GenericLoadable.Loaded)?.let { - whenConfirmed(it.loaded) - } ?: run { - val account = accountViewModel.account.saveable.observeAsState() - if (account.value?.account?.hasPendingAttestations(note) == true) { - whenPending() - } - } -} - -@Composable -fun LoadCityName( - geohash: GeoHash, - content: @Composable (String) -> Unit, -) { - val context = LocalContext.current - var cityName by remember(geohash) { mutableStateOf(geohash.toString()) } - - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null } - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } - } - } - - content(cityName) -} - -@Composable -fun DisplayLocation( - geohashStr: String, - nav: (String) -> Unit, -) { - val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() - if (geoHash != null) { - LoadCityName(geoHash) { cityName -> - ClickableText( - text = AnnotatedString(cityName), - onClick = { nav("Geohash/$geoHash") }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - } - } -} - @Composable fun FirstUserInfoRow( baseNote: Note, showAuthorPicture: Boolean, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { - val isRepost by - remember(baseNote) { - derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent } - } + Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) { + val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent val isCommunityPost by remember(baseNote) { @@ -2810,9 +1137,9 @@ fun FirstUserInfoRow( if (showAuthorPicture) { NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) Spacer(HalfPadding) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) + NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor) } else { - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) + NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor) } if (isRepost) { @@ -2823,61 +1150,68 @@ fun FirstUserInfoRow( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.let { + DisplayEditStatus(it) + } + } + TimeAgo(baseNote) - MoreOptionsButton(baseNote, accountViewModel) + MoreOptionsButton(baseNote, editState, accountViewModel, nav) } } @Composable -private fun BoostedMark() { - Text( - stringResource(id = R.string.boosted), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) -} - -@Composable -fun MoreOptionsButton( +fun observeEdits( baseNote: Note, accountViewModel: AccountViewModel, -) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } - - IconButton( - modifier = Size24Modifier, - onClick = enablePopup, - ) { - VerticalDotsIcon(R.string.note_options) - - NoteDropDownMenu( - baseNote, - popupExpanded, - accountViewModel, - ) +): State> { + if (baseNote.event !is TextNoteEvent) { + return remember { mutableStateOf(GenericLoadable.Empty()) } } -} -@Composable -fun TimeAgo(note: Note) { - val time = remember(note) { note.createdAt() } ?: return - TimeAgo(time) -} + val editState = + remember(baseNote.idHex) { + val cached = accountViewModel.cachedModificationEventsForNote(baseNote) + mutableStateOf( + if (cached != null) { + if (cached.isEmpty()) { + GenericLoadable.Empty() + } else { + val state = EditState() + state.updateModifications(cached) + GenericLoadable.Loaded(state) + } + } else { + GenericLoadable.Loading() + }, + ) + } -@Composable -fun TimeAgo(time: Long) { - val context = LocalContext.current - val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } + val updatedNote by baseNote.live().innerModifications.observeAsState() - Text( - text = timeStr, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) + LaunchedEffect(key1 = updatedNote) { + updatedNote?.note?.let { + accountViewModel.findModificationEventsForNote(it) { newModifications -> + if (newModifications.isEmpty()) { + if (editState.value !is GenericLoadable.Empty) { + editState.value = GenericLoadable.Empty() + } + } else { + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications) + } else { + val state = EditState() + state.updateModifications(newModifications) + editState.value = GenericLoadable.Loaded(state) + } + } + } + } + } + + return editState } @Composable @@ -2939,28 +1273,6 @@ private fun RenderAuthorImages( } } -@Composable -fun LoadChannel( - baseChannelHex: String, - accountViewModel: AccountViewModel, - content: @Composable (Channel) -> Unit, -) { - var channel by - remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) - } - - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { channel = newChannel } - } - } - } - - channel?.let { content(it) } -} - @Composable private fun ChannelNotePicture( baseChannel: Channel, @@ -3006,1580 +1318,3 @@ private fun RepostNoteAuthorPicture( }, ) } - -@Composable -fun DisplayHighlight( - highlight: String, - authorHex: String?, - url: String?, - postAddress: ATag?, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val quote = - remember { - highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } - } - - TranslatableRichTextViewer( - quote, - canPreview = canPreview && !makeItShort, - remember { Modifier.fillMaxWidth() }, - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) - - DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun DisplayQuoteAuthor( - authorHex: String, - url: String?, - postAddress: ATag?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } - - if (userBase == null) { - LaunchedEffect(Unit) { - accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase } - } - } - - val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - verticalArrangement = Arrangement.Center, - ) { - userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) } - - url?.let { url -> LoadAndDisplayUrl(url) } - - postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } - } -} - -@Composable -private fun LoadAndDisplayPost( - postAddress: ATag, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - LoadAddressableNote(aTag = postAddress, accountViewModel) { - it?.let { note -> - val noteEvent by - note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) - - val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } - - title?.let { - Text(remember { "-" }, maxLines = 1) - ClickableText( - text = AnnotatedString(title), - onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) - } - } - } -} - -@Composable -private fun LoadAndDisplayUrl(url: String) { - val validatedUrl = - remember { - try { - URL(url) - } catch (e: Exception) { - Log.w("Note Compose", "Invalid URI: $url") - null - } - } - - validatedUrl?.host?.let { host -> - Text(remember { "-" }, maxLines = 1) - ClickableUrl(urlText = host, url = url) - } -} - -@Composable -fun LoadAndDisplayUser( - userBase: User, - nav: (String) -> Unit, -) { - LoadAndDisplayUser(userBase, "User/${userBase.pubkeyHex}", nav) -} - -@Composable -fun LoadAndDisplayUser( - userBase: User, - route: String, - nav: (String) -> Unit, -) { - val userState by userBase.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - maxLines = 1, - route = route, - nav = nav, - tags = userTags, - ) - } -} - -@Composable -fun BadgeDisplay(baseNote: Note) { - val background = MaterialTheme.colorScheme.background - val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return - - val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } - val name = remember { badgeData.name() } - val description = remember { badgeData.description() } - - var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } - var imageResult by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = imageResult) { - launch(Dispatchers.IO) { - imageResult?.let { - val backgroundColor = - it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) - val colorFromImage = Color(backgroundColor) - val textBackground = - if (colorFromImage.luminance() > 0.5) { - lightColorScheme().onBackground - } else { - darkColorScheme().onBackground - } - - launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } - } - } - } - - Row( - modifier = - Modifier - .padding(10.dp) - .clip(shape = CutCornerShape(20, 20, 20, 20)) - .border( - 5.dp, - MaterialTheme.colorScheme.mediumImportanceLink, - CutCornerShape(20), - ) - .background(backgroundFromImage.first), - ) { - RenderBadge( - image, - name, - backgroundFromImage.second, - description, - ) { - if (imageResult == null) { - imageResult = it.result - } - } - } -} - -@Composable -private fun RenderBadge( - image: String?, - name: String?, - backgroundFromImage: Color, - description: String?, - onSuccess: (AsyncImagePainter.State.Success) -> Unit, -) { - Column { - image.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.badge_award_image_for, - name ?: "", - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - onSuccess = onSuccess, - ) - } - - name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp), - color = backgroundFromImage, - ) - } - - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun FileHeaderDisplay( - note: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return - - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - ) - }, - ) - } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } -} - -@Composable -fun VideoDisplay( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return - - val title = event.title() - val summary = event.content.ifBlank { null }?.takeIf { title != it } - val image = event.thumb() ?: event.image() - val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = event.thumb() ?: event.image(), - ) - }, - ) - } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (isYouTube) { - val uri = LocalUriHandler.current - Row( - modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, - ) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - } else { - ZoomableContentView( - content = content, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - - title?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - ) - } - - summary?.let { - TranslatableRichTextViewer( - content = it, - canPreview = canPreview && !makeItShort, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (event.hasHashtags()) { - Row( - Modifier.fillMaxWidth(), - ) { - DisplayUncitedHashtags( - remember(event) { event.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) - } - } - } - } -} - -@Composable -fun FileStorageHeaderDisplay( - baseNote: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return - val dataEventId = eventHeader.dataEventId() ?: return - - LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> - if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) - } - } -} - -@Composable -private fun ObserverAndRenderNIP95( - header: Note, - content: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - - val appContext = LocalContext.current.applicationContext - - val noteState by content.live().metadata.observeAsState() - - val content by - remember(noteState) { - // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note - val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } - val blurHash = eventHeader.blurhash() - val dimensions = eventHeader.dimensions() - val description = eventHeader.alt() ?: eventHeader.content - val mimeType = eventHeader.mimeType() - - val newContent = - if (mimeType?.startsWith("image") == true) { - MediaLocalImage( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - blurhash = blurHash, - isVerified = true, - uri = uri, - ) - } else { - MediaLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri, - authorName = header.author?.toBestDisplayName(), - ) - } - - mutableStateOf(newContent) - } - - Crossfade(targetState = content) { - if (it != null) { - SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView( - content = it, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } - } - } -} - -@Composable -fun AudioTrackHeader( - noteEvent: AudioTrackEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val content = remember { noteEvent.content() } - val participants = remember { noteEvent.participants() } - - var participantUsers by remember { mutableStateOf>>(emptyList()) } - - LaunchedEffect(key1 = participants) { - accountViewModel.loadParticipants(participants) { participantUsers = it } - } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Row { - subject?.let { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - - participantUsers.forEach { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(top = 5.dp, start = 10.dp, end = 10.dp) - .clickable { - nav("User/${it.second.pubkeyHex}") - }, - ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(Modifier.width(5.dp)) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(Modifier.width(5.dp)) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } - } - } - - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - cover?.let { cover -> - LoadThumbAndThenVideoView( - videoUri = media, - title = noteEvent.subject(), - thumbUri = cover, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel, - ) - } - ?: VideoView( - videoUri = media, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - } - } - } -} - -@Composable -fun AudioHeader( - noteEvent: AudioHeaderEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } - val content = remember { noteEvent.content().ifBlank { null } } - - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - waveform = waveform, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = note.toNostrUri(), - ) - } - } - - content?.let { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - ) { - TranslatableRichTextViewer( - content = it, - canPreview = true, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, content ?: "", nav) - } - } - } - } -} - -@Composable -fun RenderGitPatchEvent( - baseNote: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? GitPatchEvent ?: return - - RenderGitPatchEvent(event, baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) -} - -@Composable -private fun RenderShortRepositoryHeader( - baseNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState = baseNote.live().metadata.observeAsState() - val note = remember(noteState) { noteState.value?.note } ?: return - val noteEvent = note.event as? GitRepositoryEvent ?: return - - Column( - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - ) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } - Text( - text = stringResource(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - noteEvent.description()?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun RenderGitPatchEvent( - noteEvent: GitPatchEvent, - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val repository = remember(noteEvent) { noteEvent.repository() } - - if (repository != null) { - LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { - if (it != null) { - RenderShortRepositoryHeader(it, accountViewModel, nav) - Spacer(modifier = DoubleVertSpacer) - } - } - } - - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } -} - -@Composable -fun RenderGitRepositoryEvent( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? GitRepositoryEvent ?: return - - RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav) -} - -@Composable -private fun RenderGitRepositoryEvent( - noteEvent: GitRepositoryEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } - val summary = remember(noteEvent) { noteEvent.description() } - val web = remember(noteEvent) { noteEvent.web() } - val clone = remember(noteEvent) { noteEvent.clone() } - - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ).padding(Size10dp), - ) { - Column { - Text( - text = stringResource(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - summary?.let { - Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - HorizontalDivider(thickness = DividerThickness) - - web?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringResource(id = R.string.git_web_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - - clone?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringResource(id = R.string.git_clone_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - } - } -} - -@Composable -fun RenderLiveActivityEvent( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Row(modifier = Modifier.padding(top = 5.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) - } - } -} - -@Composable -fun RenderLiveActivityEventInner( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - - val eventUpdates by baseNote.live().metadata.observeAsState() - - val media = remember(eventUpdates) { noteEvent.streaming() } - val cover = remember(eventUpdates) { noteEvent.image() } - val subject = remember(eventUpdates) { noteEvent.title() } - val content = remember(eventUpdates) { noteEvent.summary() } - val participants = remember(eventUpdates) { noteEvent.participants() } - val status = remember(eventUpdates) { noteEvent.status() } - val starts = remember(eventUpdates) { noteEvent.starts() } - - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(vertical = 5.dp) - .fillMaxWidth(), - ) { - subject?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = StdHorzSpacer) - - Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { - when (it) { - STATUS_LIVE -> { - media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } - } - STATUS_PLANNED -> { - ScheduledFlag(starts) - } - } - } - } - - var participantUsers by remember { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = eventUpdates) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - media?.let { media -> - if (status == STATUS_LIVE) { - CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> - if (isOnline) { - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - title = subject, - artworkUri = cover, - authorName = baseNote.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = "nostr:${baseNote.toNEvent()}", - ) - } - } else { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(10.dp) - .height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_is_offline), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - } else if (status == STATUS_ENDED) { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(10.dp) - .height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_has_ended), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - - participantUsers.forEach { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(vertical = 5.dp) - .clickable { nav("User/${it.second.pubkeyHex}") }, - ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(StdHorzSpacer) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(StdHorzSpacer) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } - } - } -} - -@Composable -private fun LongFormHeader( - noteEvent: LongTextNoteEvent, - note: Note, - accountViewModel: AccountViewModel, -) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { - noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } - } - - Row( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } - - summary?.let { - Spacer(modifier = StdVertSpacer) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -@Composable -private fun RenderWikiContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? WikiNoteEvent ?: return - - WikiNoteHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun WikiNoteHeader( - noteEvent: WikiNoteEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { - noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } - } - val image = remember(noteEvent) { noteEvent.image() } - - Row( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } - - summary?.let { - Spacer(modifier = StdVertSpacer) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -@Composable -private fun RenderClassifieds( - noteEvent: ClassifiedsEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } - - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - Row { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), - verticalAlignment = CenterVertically, - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - price?.let { - val priceTag = - remember(noteEvent) { - val newAmount = - price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount - - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - }, - ) - } - } - - if (summary != null || location != null) { - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), - verticalAlignment = CenterVertically, - ) { - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - /* - Column { - location?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 5.dp) - ) - } - - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { - note.author?.let { - accountViewModel.createChatRoomFor(it) { - nav("Room/$it") - } - } - }, - contentPadding = ZeroPadding, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White - ) - } - } - - */ - } - } - - Spacer(modifier = DoubleVertSpacer) - } - } -} - -@Composable -fun CreateImageHeader( - note: Note, - accountViewModel: AccountViewModel, -) { - val banner = remember(note.author?.info) { note.author?.info?.banner } - - Box { - banner?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = - remember { - Modifier - .fillMaxWidth() - .height(150.dp) - }, - ) - - Box( - remember { - Modifier - .width(75.dp) - .height(75.dp) - .padding(10.dp) - .align(Alignment.BottomStart) - }, - ) { - NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) - } - } -} - -@Preview -@Composable -fun RenderEyeGlassesPrescriptionPreview() { - val accountViewModel = mockAccountViewModel() - val nav: (String) -> Unit = {} - - val prescriptionEvent = Event.fromJson("{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}") as FhirResourceEvent - - RenderFhirResource(prescriptionEvent, accountViewModel, nav) -} - -@Composable -fun RenderFhirResource( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? FhirResourceEvent ?: return - - RenderFhirResource(event, accountViewModel, nav) -} - -@Composable -fun RenderFhirResource( - event: FhirResourceEvent, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var baseResource: Resource? by remember(event) { - mutableStateOf(null) - } - - LaunchedEffect(key1 = event) { - withContext(Dispatchers.IO) { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - - try { - baseResource = mapper.readValue(event.content, Resource::class.java) - } catch (e: Exception) { - Log.e("RenderEyeGlassesPrescription", "Parser error", e) - } - } - } - - baseResource?.let { resource -> - when (resource) { - is Bundle -> { - val db = resource.entry.associate { it.id to it } - val vision = resource.entry.filterIsInstance(VisionPrescription::class.java) - - vision.firstOrNull()?.let { - RenderEyeGlassesPrescription(it, db, accountViewModel, nav) - } - } - is VisionPrescription -> { - val db = mapOf(resource.id to resource) - RenderEyeGlassesPrescription(resource, db, accountViewModel, nav) - } - else -> { - } - } - } -} - -@Composable -fun RenderEyeGlassesPrescription( - visionPrescription: VisionPrescription, - db: Map, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(Size10dp), - ) { - val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" } - val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" } - - Text( - "Eyeglasses Prescription", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(StdVertSpacer) - - visionPrescription.patient?.reference?.let { - val patient = findReferenceInDb(it, db) as? Patient - - patient?.name?.firstOrNull()?.assembleName()?.let { - Text( - text = "Patient: $it", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - ) - } - } - visionPrescription.status?.let { - Text( - text = "Status: ${it.capitalize()}", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - ) - } - - Spacer(DoubleVertSpacer) - - RenderEyeGlassesPrescriptionHeaderRow() - HorizontalDivider(thickness = DividerThickness) - - rightEye?.let { - RenderEyeGlassesPrescriptionRow(data = it) - HorizontalDivider(thickness = DividerThickness) - } - - leftEye?.let { - RenderEyeGlassesPrescriptionRow(data = it) - HorizontalDivider(thickness = DividerThickness) - } - - Spacer(DoubleVertSpacer) - - visionPrescription.prescriber?.reference?.let { - val practitioner = findReferenceInDb(it, db) as? Practitioner - - practitioner?.name?.firstOrNull()?.assembleName()?.let { - Text( - text = "Signed by: $it", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - textAlign = TextAlign.Right, - ) - } - } - } -} - -@Composable -fun RenderEyeGlassesPrescriptionHeaderRow() { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Eye", - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = "Sph", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = "Cyl", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = "Axis", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = "Add", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - } -} - -@Composable -fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val numberFormat = DecimalFormat("##.00") - val integerFormat = DecimalFormat("###") - - Text( - text = data.eye?.capitalize() ?: "Unknown", - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = formatOrBlank(data.sphere, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = formatOrBlank(data.cylinder, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = formatOrBlank(data.axis, integerFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = formatOrBlank(data.add, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - } -} - -fun formatOrBlank( - amount: Double?, - numberFormat: NumberFormat, -): String { - if (amount == null) return "" - if (Math.abs(amount) < 0.01) return "" - return numberFormat.format(amount) -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d2bbb7f59..a60b15af4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -52,11 +51,12 @@ import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -128,13 +128,6 @@ val externalLinkForNote = { note: Note -> } } -@Composable -private fun VerticalDivider(color: Color) = - Divider( - color = color, - modifier = Modifier.fillMaxHeight().width(1.dp), - ) - @Composable fun LongPressToQuickAction( baseNote: Note, @@ -263,7 +256,7 @@ private fun RenderMainPopup( onDismiss() } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id), @@ -274,7 +267,7 @@ private fun RenderMainPopup( onDismiss() } } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id), @@ -287,7 +280,7 @@ private fun RenderMainPopup( } if (!isOwnNote) { - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.Block, @@ -302,9 +295,8 @@ private fun RenderMainPopup( } } } - Divider( + HorizontalDivider( color = primaryLight, - modifier = Modifier.fillMaxWidth().width(1.dp), ) Row(modifier = Modifier.height(IntrinsicSize.Min)) { if (isOwnNote) { @@ -337,7 +329,7 @@ private fun RenderMainPopup( } } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( icon = ImageVector.vectorResource(id = R.drawable.relays), label = stringResource(R.string.broadcast), @@ -346,7 +338,7 @@ private fun RenderMainPopup( // showSelectTextDialog = true onDismiss() } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share), @@ -375,7 +367,7 @@ private fun RenderMainPopup( } if (!isOwnNote) { - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.Report, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index e40d7ac40..5b3d75f7c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -68,7 +68,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -101,7 +100,7 @@ fun PollNote( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel") + val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}") pollViewModel.load(accountViewModel.account, baseNote) @@ -126,11 +125,9 @@ fun PollNote( ) { WatchZapsAndUpdateTallies(baseNote, pollViewModel) - val tallies by pollViewModel.tallies.collectAsStateWithLifecycle() - - tallies.forEach { poll_op -> + pollViewModel.tallies.forEach { option -> OptionNote( - poll_op, + option, pollViewModel, baseNote, accountViewModel, @@ -167,9 +164,9 @@ private fun OptionNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp), ) { - if (!pollViewModel.canZap()) { + if (!pollViewModel.canZap.value) { val color = - if (poolOption.consensusThreadhold) { + if (poolOption.consensusThreadhold.value) { Color.Green.copy(alpha = 0.32f) } else { MaterialTheme.colorScheme.mediumImportanceLink @@ -181,8 +178,7 @@ private fun OptionNote( pollViewModel = pollViewModel, nonClickablePrepend = { RenderOptionAfterVote( - poolOption.descriptor, - poolOption.tally.toFloat(), + poolOption, color, canPreview, tags, @@ -220,8 +216,7 @@ private fun OptionNote( @Composable private fun RenderOptionAfterVote( - description: String, - totalRatio: Float, + poolOption: PollOption, color: Color, canPreview: Boolean, tags: ImmutableListOfLists, @@ -229,10 +224,9 @@ private fun RenderOptionAfterVote( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" } - Box( - Modifier.fillMaxWidth(0.75f) + Modifier + .fillMaxWidth(0.75f) .clip(shape = QuoteBorder) .border( 2.dp, @@ -243,7 +237,9 @@ private fun RenderOptionAfterVote( LinearProgressIndicator( modifier = Modifier.matchParentSize(), color = color, - progress = totalRatio, + progress = { + poolOption.tally.value.toFloat() + }, ) Row( @@ -251,23 +247,34 @@ private fun RenderOptionAfterVote( ) { Column( horizontalAlignment = Alignment.End, - modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) }, + modifier = + remember { + Modifier + .padding(horizontal = 10.dp) + .width(45.dp) + }, ) { Text( - text = totalPercentage, + text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%", fontWeight = FontWeight.Bold, ) } Column( - modifier = remember { Modifier.fillMaxWidth().padding(15.dp) }, + modifier = + remember { + Modifier + .fillMaxWidth() + .padding(vertical = 15.dp, horizontal = 10.dp) + }, ) { TranslatableRichTextViewer( - description, + poolOption.descriptor, canPreview, - remember { Modifier }, + Modifier, tags, backgroundColor, + poolOption.descriptor, accountViewModel, nav, ) @@ -286,7 +293,8 @@ private fun RenderOptionBeforeVote( nav: (String) -> Unit, ) { Box( - Modifier.fillMaxWidth(0.75f) + Modifier + .fillMaxWidth(0.75f) .clip(shape = QuoteBorder) .border( 2.dp, @@ -300,6 +308,7 @@ private fun RenderOptionBeforeVote( remember { Modifier.padding(15.dp) }, tags, backgroundColor, + id = description, accountViewModel, nav, ) @@ -358,7 +367,7 @@ fun ZapVote( R.string.poll_unable_to_vote, R.string.poll_author_no_vote, ) - } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { + } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn.value) { // only allow one vote per option when min==max, i.e. atomic vote amount specified accountViewModel.toast( R.string.poll_unable_to_vote, @@ -443,7 +452,7 @@ fun ZapVote( clickablePrepend() - if (poolOption.zappedByLoggedIn) { + if (poolOption.zappedByLoggedIn.value) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -471,8 +480,8 @@ fun ZapVote( } // only show tallies after a user has zapped note - if (!pollViewModel.canZap()) { - val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) } + if (!pollViewModel.canZap.value) { + val amountStr = remember(poolOption.zappedValue.value) { showAmount(poolOption.zappedValue.value) } Text( text = amountStr, fontSize = Font14SP, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 0fb6008fa..5f4949bc8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.note -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account @@ -36,20 +37,18 @@ import com.vitorpamplona.quartz.events.VALUE_MAXIMUM import com.vitorpamplona.quartz.events.VALUE_MINIMUM import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode -@Immutable +@Stable data class PollOption( val option: Int, val descriptor: String, - val zappedValue: BigDecimal, - val tally: BigDecimal, - val consensusThreadhold: Boolean, - val zappedByLoggedIn: Boolean, + var zappedValue: MutableState = mutableStateOf(BigDecimal.ZERO), + var tally: MutableState = mutableStateOf(BigDecimal.ZERO), + var consensusThreadhold: MutableState = mutableStateOf(false), + var zappedByLoggedIn: MutableState = mutableStateOf(false), ) @Stable @@ -70,65 +69,67 @@ class PollNoteViewModel : ViewModel() { private var totalZapped: BigDecimal = BigDecimal.ZERO private var wasZappedByLoggedInAccount: Boolean = false - private val _tallies = MutableStateFlow>(emptyList()) - val tallies = _tallies.asStateFlow() + var canZap = mutableStateOf(false) + var tallies: List = emptyList() fun load( acc: Account, note: Note?, ) { - account = acc - pollNote = note - pollEvent = pollNote?.event as PollNoteEvent - pollOptions = pollEvent?.pollOptions() - valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) - valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) - valueMinimumBD = valueMinimum?.let { BigDecimal(it) } - valueMaximumBD = valueMaximum?.let { BigDecimal(it) } - consensusThreshold = - pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() - closedAt = pollEvent?.getTagLong(CLOSED_AT) + if (acc != account || pollNote != note) { + account = acc + pollNote = note + pollEvent = pollNote?.event as PollNoteEvent + pollOptions = pollEvent?.pollOptions() + valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) + valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) + valueMinimumBD = valueMinimum?.let { BigDecimal(it) } + valueMaximumBD = valueMaximum?.let { BigDecimal(it) } + consensusThreshold = + pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() + closedAt = pollEvent?.getTagLong(CLOSED_AT) + + totalZapped = BigDecimal.ZERO + wasZappedByLoggedInAccount = false + + canZap.value = checkIfCanZap() + + tallies = pollOptions?.keys?.map { option -> + PollOption( + option, + pollOptions?.get(option) ?: "", + ) + } ?: emptyList() + } } fun refreshTallies() { viewModelScope.launch(Dispatchers.Default) { totalZapped = totalZapped() wasZappedByLoggedInAccount = false - account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true } + account?.calculateIfNoteWasZappedByAccount(pollNote) { + wasZappedByLoggedInAccount = true + canZap.value = checkIfCanZap() + } - val newOptions = - pollOptions?.keys?.map { option -> - val zappedInOption = zappedPollOptionAmount(option) + tallies.forEach { + val zappedValue = zappedPollOptionAmount(it.option) + val tallyValue = + if (totalZapped > BigDecimal.ZERO) { + zappedValue.divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO + } - val myTally = - if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { - zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) - } else { - BigDecimal.ZERO - } - - val cachedZappedByLoggedIn = - account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false - - val consensus = consensusThreshold != null && myTally >= consensusThreshold!! - - PollOption( - option, - pollOptions?.get(option) ?: "", - zappedInOption, - myTally, - consensus, - cachedZappedByLoggedIn, - ) - } - - _tallies.emit( - newOptions ?: emptyList(), - ) + it.zappedValue.value = zappedValue + it.tally.value = tallyValue + it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! + it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false + } } } - fun canZap(): Boolean { + fun checkIfCanZap(): Boolean { val account = account ?: return false val note = pollNote ?: return false return account.userProfile() != note.author && !wasZappedByLoggedInAccount diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 794a20752..49c4f39bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -61,6 +61,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -99,8 +100,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DarkerGreen @@ -145,6 +148,7 @@ import kotlin.math.roundToInt fun ReactionsRow( baseNote: Note, showReactionDetail: Boolean, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -152,7 +156,7 @@ fun ReactionsRow( Spacer(modifier = HalfDoubleVertSpacer) - InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav) + InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, editState, accountViewModel, nav) Spacer(modifier = HalfDoubleVertSpacer) @@ -169,6 +173,7 @@ private fun InnerReactionRow( baseNote: Note, showReactionDetail: Boolean, wantsToSeeReactions: MutableState, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -190,6 +195,7 @@ private fun InnerReactionRow( three = { BoostWithDialog( baseNote, + editState, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav, @@ -301,7 +307,7 @@ fun RenderZapRaiser( LinearProgressIndicator( modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, color = color, - progress = zapraiserStatus.progress, + progress = { zapraiserStatus.progress }, ) if (details) { @@ -495,6 +501,7 @@ private fun WatchZapAndRenderGallery( @Composable private fun BoostWithDialog( baseNote: Note, + editState: State>, grayTint: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -507,6 +514,7 @@ private fun BoostWithDialog( onClose = { wantsToQuote = null }, baseReplyTo = null, quote = wantsToQuote, + version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value, accountViewModel = accountViewModel, nav = nav, ) @@ -528,6 +536,7 @@ private fun BoostWithDialog( onClose = { wantsToFork = null }, baseReplyTo = replyTo, fork = wantsToFork, + version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value, accountViewModel = accountViewModel, nav = nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 55a1a16e7..a39aa5c26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -101,7 +101,7 @@ fun RelayCompose( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 71c661083..2d946e5a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -20,11 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Icon @@ -37,14 +45,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer -import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifier -import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalLayoutApi::class) @@ -58,16 +69,18 @@ fun RelayBadges( val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays) - Spacer(DoubleVertSpacer) + Spacer(StdVertSpacer) // FlowRow Seems to be a lot faster than LazyVerticalGrid - FlowRow { - if (expanded) { - relayList?.forEach { RenderRelay(it, accountViewModel, nav) } - } else { - relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } - relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } - relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) { + FlowRow(modifier = Modifier.fillMaxWidth()) { + if (expanded) { + relayList?.forEach { RenderRelay(it, accountViewModel, nav) } + } else { + relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + } } } @@ -76,6 +89,62 @@ fun RelayBadges( } } +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +fun RelayIconLayoutPreview() { + Column(modifier = Modifier.width(55.dp)) { + Spacer(StdVertSpacer) + + // FlowRow Seems to be a lot faster than LazyVerticalGrid + Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) { + FlowRow { + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black), + ) + } + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black)) + } + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black)) + } + + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black)) + } + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black)) + } + Box( + modifier = Modifier.size(17.dp), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black)) + } + } + } + + ShowMoreRelaysButton { } + } +} + @Composable private fun ShowMoreRelaysButton(onClick: () -> Unit) { Row( @@ -84,13 +153,11 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) { verticalAlignment = Alignment.Top, ) { IconButton( - modifier = ShowMoreRelaysButtonIconButtonModifier, onClick = onClick, ) { Icon( imageVector = Icons.Default.ExpandMore, contentDescription = stringResource(id = R.string.expand_relay_list), - modifier = ShowMoreRelaysButtonIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index cac24a089..ee5ecfcde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight @@ -47,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -59,7 +57,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size15dp +import com.vitorpamplona.amethyst.ui.theme.Size17dp import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.relayIconModifier @@ -164,13 +162,12 @@ fun RenderRelay( val context = LocalContext.current val interactionSource = remember { MutableInteractionSource() } - val ripple = rememberRipple(bounded = false, radius = Size15dp) + val ripple = rememberRipple(bounded = false, radius = Size17dp) val clickableModifier = remember(relay) { Modifier - .padding(1.dp) - .size(Size15dp) + .size(Size17dp) .clickable( role = Role.Button, interactionSource = interactionSource, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index b6861152c..68d1109c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.placeholderText diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index e81d9bee4..bd8599b8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -51,7 +51,7 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -408,7 +408,7 @@ fun UpdateZapAmountDialog( ) } - Divider( + HorizontalDivider( modifier = Modifier.padding(vertical = 10.dp), thickness = DividerThickness, ) @@ -445,7 +445,7 @@ fun UpdateZapAmountDialog( ) { Icon( painter = painterResource(R.drawable.alby), - null, + contentDescription = stringResource(id = R.string.accessibility_navigate_to_alby), modifier = Modifier.size(24.dp), tint = Color.Unspecified, ) @@ -454,7 +454,7 @@ fun UpdateZapAmountDialog( IconButton(onClick = { qrScanning = true }) { Icon( painter = painterResource(R.drawable.ic_qrcode), - null, + contentDescription = stringResource(id = R.string.accessibility_scan_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index d808056d3..6026fb233 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -68,7 +68,7 @@ fun UserCompose( } if (showDiviser) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index b49ef2786..523afae1d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.fadeIn @@ -34,33 +33,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Divider -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Dp -import androidx.core.content.ContextCompat import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.R @@ -69,11 +55,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.quartz.encoders.HexKey import kotlinx.collections.immutable.ImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun NoteAuthorPicture( @@ -439,270 +422,3 @@ fun WatchUserFollows( onFollowChanges(showFollowingMark) } - -@Immutable -data class DropDownParams( - val isFollowingAuthor: Boolean, - val isPrivateBookmarkNote: Boolean, - val isPublicBookmarkNote: Boolean, - val isLoggedUser: Boolean, - val isSensitive: Boolean, - val showSensitiveContent: Boolean?, -) - -@Composable -fun NoteDropDownMenu( - note: Note, - popupExpanded: MutableState, - accountViewModel: AccountViewModel, -) { - var reportDialogShowing by remember { mutableStateOf(false) } - - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null, - ), - ) - } - - val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } - - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current - val appContext = LocalContext.current.applicationContext - val actContext = LocalContext.current - - WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> - if (state != newState) { - state = newState - } - } - - val scope = rememberCoroutineScope() - - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringResource(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem - accountViewModel.follow(author) - onDismiss() - }, - ) - Divider() - } - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_text)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_user_pubkey)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_note_id)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.quick_action_share)) }, - onClick = { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - actContext.getString(R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) - onDismiss() - }, - ) - Divider() - DropdownMenuItem( - text = { Text(stringResource(R.string.broadcast)) }, - onClick = { - accountViewModel.broadcast(note) - onDismiss() - }, - ) - Divider() - if (accountViewModel.account.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.timestamp_pending)) }, - onClick = { - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.timestamp_it)) }, - onClick = { - accountViewModel.timestamp(note) - onDismiss() - }, - ) - } - Divider() - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, - onClick = { - accountViewModel.removePrivateBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, - onClick = { - accountViewModel.addPrivateBookmark(note) - onDismiss() - }, - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, - onClick = { - accountViewModel.removePublicBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, - onClick = { - accountViewModel.addPublicBookmark(note) - onDismiss() - }, - ) - } - Divider() - if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.hideSensitiveContent() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.disableContentWarnings() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent != null) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_see_warnings)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.seeContentWarnings() - onDismiss() - } - }, - ) - } - Divider() - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringResource(R.string.request_deletion)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.delete(note) - onDismiss() - } - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) - } - } - - if (reportDialogShowing) { - ReportNoteDialog(note = note, accountViewModel = accountViewModel) { - reportDialogShowing = false - onDismiss() - } - } -} - -@Composable -fun WatchBookmarksFollowsAndAccount( - note: Note, - accountViewModel: AccountViewModel, - onNew: (DropDownParams) -> Unit, -) { - val followState by accountViewModel.userProfile().live().follows.observeAsState() - val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() - val showSensitiveContent by - accountViewModel.showSensitiveContentChanges.observeAsState( - accountViewModel.account.showSensitiveContent, - ) - - LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - launch(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = - DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = showSensitiveContent, - ) - - launch(Dispatchers.Main) { - onNew( - newState, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 3c2e42730..7fd4de335 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -72,67 +71,27 @@ fun UsernameDisplay( fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified, ) { - val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() } } - val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { - if (it != null) { - UserNameDisplay( - it.bestUsername(), - it.bestDisplayName(), - npubDisplay, - it.tags, - weight, - showPlayButton, - fontWeight, - textColor, - ) + val name = it?.bestDisplayName() ?: it?.bestUsername() + if (name != null) { + UserDisplay(name, it?.tags, weight, showPlayButton, fontWeight, textColor) } else { - NPubDisplay(npubDisplay, weight, fontWeight, textColor) + NPubDisplay(baseUser, weight, fontWeight, textColor) } } } @Composable -private fun UserNameDisplay( - bestUserName: String?, - bestDisplayName: String?, - npubDisplay: String, - tags: ImmutableListOfLists?, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, -) { - if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { - UserAndUsernameDisplay( - bestDisplayName.trim(), - tags, - bestUserName.trim(), - modifier, - showPlayButton, - fontWeight, - textColor, - ) - } else if (bestDisplayName != null) { - UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else if (bestUserName != null) { - UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else { - NPubDisplay(npubDisplay, modifier, fontWeight, textColor) - } -} - -@Composable -fun NPubDisplay( - npubDisplay: String, +private fun NPubDisplay( + user: User, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified, ) { Text( - text = npubDisplay, + text = remember { user.pubkeyDisplayHex() }, fontWeight = fontWeight, modifier = modifier, maxLines = 1, @@ -160,41 +119,7 @@ private fun UserDisplay( modifier = modifier, color = textColor, ) - if (showPlayButton) { - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) - } - } -} -@Composable -private fun UserAndUsernameDisplay( - bestDisplayName: String, - tags: ImmutableListOfLists?, - bestUserName: String, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, -) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - fontWeight = fontWeight, - maxLines = 1, - modifier = modifier, - color = textColor, - ) - /* - CreateTextWithEmoji( - text = remember { "@$bestUserName" }, - tags = tags, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - - )*/ if (showPlayButton) { Spacer(StdHorzSpacer) DrawPlayName(bestDisplayName) @@ -207,12 +132,7 @@ fun DrawPlayName(name: String) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - DrawPlayNameIcon { speak(name, context, lifecycleOwner) } -} - -@Composable -fun DrawPlayNameIcon(onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { + IconButton(onClick = { speak(name, context, lifecycleOwner) }, modifier = StdButtonSizeModifier) { PlayIcon( modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 91d114954..d5d293cd6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,7 +91,7 @@ fun ZapNoteCompose( ) { baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt index 593f6bc3a..f00c680f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt @@ -69,9 +69,9 @@ import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 9f93e498e..2400fc84e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -132,7 +132,7 @@ fun ZapUserSetCompose( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt index e656751b1..9ec25686a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt new file mode 100644 index 000000000..acb98798c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun BoostedMark() { + Text( + stringResource(id = R.string.boosted), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = HalfStartPadding, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt new file mode 100644 index 000000000..055f77bd0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader +import com.vitorpamplona.amethyst.ui.theme.imageHeaderBannerSize + +@Composable +fun DefaultImageHeader( + note: Note, + accountViewModel: AccountViewModel, +) { + Box { + note.author?.info?.banner?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = imageHeaderBannerSize, + ) + + Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { + NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 0685e1090..1459b0c81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt new file mode 100644 index 000000000..0981fc846 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun DisplayEditStatus(editState: EditState) { + ClickableText( + text = + buildAnnotatedString { + if (editState.showingVersion.value == editState.originalVersionId()) { + append(stringResource(id = R.string.original)) + } else if (editState.showingVersion.value == editState.lastVersionId()) { + append(stringResource(id = R.string.edited)) + } else { + append(stringResource(id = R.string.edited_number, editState.versionId())) + } + }, + onClick = { + editState.nextModification() + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.placeholderText, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + modifier = HalfStartPadding, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt similarity index 86% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cd9f71574..54d3d424c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,8 +37,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun DisplayFollowingHashtagsInPost( @@ -50,13 +48,11 @@ fun DisplayFollowingHashtagsInPost( var firstTag by remember(baseNote) { mutableStateOf(null) } LaunchedEffect(key1 = userFollowState) { - launch(Dispatchers.Default) { - val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() - val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags) + val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() + val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags) - if (firstTag != newFirstTag) { - launch(Dispatchers.Main) { firstTag = newFirstTag } - } + if (firstTag != newFirstTag) { + firstTag = newFirstTag } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt new file mode 100644 index 000000000..4f15be3cc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.ui.note.LoadCityName +import com.vitorpamplona.amethyst.ui.theme.Font14SP + +@Composable +fun DisplayLocation( + geohashStr: String, + nav: (String) -> Unit, +) { + LoadCityName(geohashStr) { cityName -> + ClickableText( + text = AnnotatedString(cityName), + onClick = { nav("Geohash/$geohashStr") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt new file mode 100644 index 000000000..4c3488c64 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.LoadOts +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import java.text.SimpleDateFormat +import java.util.Date + +@Composable +fun DisplayOts( + note: Note, + accountViewModel: AccountViewModel, +) { + LoadOts( + note, + accountViewModel, + whenConfirmed = { unixtimestamp -> + val context = LocalContext.current + val timeStr by remember(unixtimestamp) { + mutableStateOf( + timeAgoNoDot( + unixtimestamp, + context = context, + ), + ) + } + + ClickableText( + text = + buildAnnotatedString { + append( + stringResource( + id = R.string.existed_since, + timeStr, + ), + ) + }, + onClick = { + val fullDateTime = + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) + + accountViewModel.toast( + context.getString(R.string.ots_info_title), + context.getString(R.string.ots_info_description, fullDateTime), + ) + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + }, + whenPending = { + Text( + stringResource(id = R.string.timestamp_pending_short), + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + }, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 184ebe35c..842dc8c5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt similarity index 99% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 2c7520103..5fc6a69ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index e185ab24f..551f60689 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt index ecdf5ad57..40d63f7e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt new file mode 100644 index 000000000..27d37e018 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -0,0 +1,393 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import android.content.Intent +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.events.TextNoteEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun MoreOptionsButton( + baseNote: Note, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + + IconButton( + modifier = Size24Modifier, + onClick = { popupExpanded.value = true }, + ) { + VerticalDotsIcon(R.string.note_options) + + NoteDropDownMenu( + baseNote, + popupExpanded, + editState, + accountViewModel, + nav, + ) + } +} + +@Immutable +data class DropDownParams( + val isFollowingAuthor: Boolean, + val isPrivateBookmarkNote: Boolean, + val isPublicBookmarkNote: Boolean, + val isLoggedUser: Boolean, + val isSensitive: Boolean, + val showSensitiveContent: Boolean?, +) + +@Composable +fun NoteDropDownMenu( + note: Note, + popupExpanded: MutableState, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var reportDialogShowing by remember { mutableStateOf(false) } + + var state by remember { + mutableStateOf( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null, + ), + ) + } + + val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } + + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + if (wantsToEditPost.value) { + // avoids changing while drafting a note and a new event shows up. + val versionLookingAt = + remember { + (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value + } + + EditPostView( + onClose = { + popupExpanded.value = false + wantsToEditPost.value = false + }, + edit = note, + versionLookingAt = versionLookingAt, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + val appContext = LocalContext.current.applicationContext + val actContext = LocalContext.current + + WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + val scope = rememberCoroutineScope() + + if (!state.isFollowingAuthor) { + DropdownMenuItem( + text = { Text(stringResource(R.string.follow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.follow(author) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_text)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_pubkey)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_note_id)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + actContext.getString(R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (note.event is TextNoteEvent) { + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit_post)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.propose_an_edit)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } + } + DropdownMenuItem( + text = { Text(stringResource(R.string.broadcast)) }, + onClick = { + accountViewModel.broadcast(note) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (accountViewModel.account.hasPendingAttestations(note)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_pending)) }, + onClick = { + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_it)) }, + onClick = { + accountViewModel.timestamp(note) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.isPrivateBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, + onClick = { + accountViewModel.removePrivateBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, + onClick = { + accountViewModel.addPrivateBookmark(note) + onDismiss() + }, + ) + } + if (state.isPublicBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, + onClick = { + accountViewModel.removePublicBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, + onClick = { + accountViewModel.addPublicBookmark(note) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.showSensitiveContent == null || state.showSensitiveContent == true) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.hideSensitiveContent() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent == null || state.showSensitiveContent == false) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.disableContentWarnings() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_see_warnings)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.seeContentWarnings() + onDismiss() + } + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.request_deletion)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + onDismiss() + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_report)) }, + onClick = { reportDialogShowing = true }, + ) + } + } + + if (reportDialogShowing) { + ReportNoteDialog(note = note, accountViewModel = accountViewModel) { + reportDialogShowing = false + onDismiss() + } + } +} + +@Composable +fun WatchBookmarksFollowsAndAccount( + note: Note, + accountViewModel: AccountViewModel, + onNew: (DropDownParams) -> Unit, +) { + val followState by accountViewModel.userProfile().live().follows.observeAsState() + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + val showSensitiveContent by + accountViewModel.showSensitiveContentChanges.observeAsState( + accountViewModel.account.showSensitiveContent, + ) + + LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { + launch(Dispatchers.IO) { + accountViewModel.isInPrivateBookmarks(note) { + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = it, + isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitive() ?: false, + showSensitiveContent = showSensitiveContent, + ) + + launch(Dispatchers.Main) { + onNew( + newState, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt new file mode 100644 index 000000000..62ccc2ce3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists + +@Composable +fun ShowForkInformation( + noteEvent: BaseTextNoteEvent, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } + if (forkedAddress != null) { + LoadAddressableNote( + aTag = forkedAddress, + accountViewModel = accountViewModel, + ) { addressableNote -> + if (addressableNote != null) { + ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) + } + } + } else if (forkedEvent != null) { + LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> + if (event != null) { + ForkInformationRowLightColor(event, modifier, accountViewModel, nav) + } + } + } +} + +@Composable +fun ForkInformationRowLightColor( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + ClickableText( + text = + buildAnnotatedString { + append(stringResource(id = R.string.forked_from)) + append(" ") + }, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.nip05, + fontSize = Font14SP, + ), + maxLines = 1, + overflow = TextOverflow.Visible, + ) + + val userState by author.live().metadata.observeAsState() + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + maxLines = 1, + route = route, + overrideColor = MaterialTheme.colorScheme.nip05, + fontSize = Font14SP, + nav = nav, + tags = userTags, + ) + } + } + } +} + +@Composable +fun ForkInformationRow( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + Text(stringResource(id = R.string.forked_from)) + Spacer(modifier = StdHorzSpacer) + + val userMetadata by author.live().userMetadataInfo.observeAsState() + + CreateClickableTextWithEmoji( + clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: author.pubkeyDisplayHex(), + maxLines = 1, + route = route, + nav = nav, + tags = userMetadata?.tags, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt new file mode 100644 index 000000000..9c48a2393 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun TimeAgo(note: Note) { + val time = remember(note) { note.createdAt() } ?: return + TimeAgo(time) +} + +@Composable +fun TimeAgo(time: Long) { + val context = LocalContext.current + val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } + + Text( + text = timeStr, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt new file mode 100644 index 000000000..fcffd450b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -0,0 +1,246 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RenderAppDefinition( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? AppDefinitionEvent ?: return + + var metadata by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = noteEvent) { + launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() } + } + + metadata?.let { + Box { + val clipboardManager = LocalClipboardManager.current + val uri = LocalUriHandler.current + + if (!it.banner.isNullOrBlank()) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + AsyncImage( + model = it.banner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .height(125.dp) + .combinedClickable( + onClick = {}, + onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, + ), + ) + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .height(125.dp), + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .padding(top = 75.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + Box(Modifier.size(100.dp)) { + it.picture?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .border( + 3.dp, + MaterialTheme.colorScheme.background, + CircleShape, + ) + .clip(shape = CircleShape) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, + ), + ) + } + } + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + + Spacer(Modifier.weight(1f)) + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(bottom = 3.dp), + ) {} + } + + val name = remember(it) { it.anyName() } + name?.let { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.padding(top = 7.dp), + ) { + CreateTextWithEmoji( + text = it, + tags = + remember { + (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() + }, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } + } + + val website = remember(it) { it.website } + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { website.let { runCatching { uri.openUri(it) } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + it.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val tags = + remember(note) { + note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList + } + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt new file mode 100644 index 000000000..78f442277 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -0,0 +1,236 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AudioHeaderEvent +import com.vitorpamplona.quartz.events.AudioTrackEvent +import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList +import java.util.Locale + +@Composable +fun RenderAudioTrack( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? AudioTrackEvent ?: return + + AudioTrackHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun AudioTrackHeader( + noteEvent: AudioTrackEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val media = remember { noteEvent.media() } + val cover = remember { noteEvent.cover() } + val subject = remember { noteEvent.subject() } + val content = remember { noteEvent.content() } + val participants = remember { noteEvent.participants() } + + var participantUsers by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(key1 = participants) { + accountViewModel.loadParticipants(participants) { participantUsers = it } + } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + subject?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(top = 5.dp, start = 10.dp, end = 10.dp) + .clickable { + nav("User/${it.second.pubkeyHex}") + }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(Modifier.width(5.dp)) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(Modifier.width(5.dp)) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } + + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + cover?.let { cover -> + LoadThumbAndThenVideoView( + videoUri = media, + title = noteEvent.subject(), + thumbUri = cover, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + ?: VideoView( + videoUri = media, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +fun RenderAudioHeader( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? AudioHeaderEvent ?: return + + AudioHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun AudioHeader( + noteEvent: AudioHeaderEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val media = remember { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val content = remember { noteEvent.content().ifBlank { null } } + + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + val tags = remember(noteEvent) { noteEvent.tags().toImmutableListOfLists() } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) + } + } + + content?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + ) { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = tags, + backgroundColor = background, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, content ?: "", nav) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt new file mode 100644 index 000000000..f6deaa3fe --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import android.graphics.Bitmap +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.get +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.request.SuccessResult +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.quartz.events.BadgeAwardEvent +import com.vitorpamplona.quartz.events.BadgeDefinitionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun BadgeDisplay(baseNote: Note) { + val background = MaterialTheme.colorScheme.background + val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return + + val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } + val name = remember { badgeData.name() } + val description = remember { badgeData.description() } + + var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } + var imageResult by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = imageResult) { + launch(Dispatchers.IO) { + imageResult?.let { + val backgroundColor = + it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) + val colorFromImage = Color(backgroundColor) + val textBackground = + if (colorFromImage.luminance() > 0.5) { + lightColorScheme().onBackground + } else { + darkColorScheme().onBackground + } + + launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } + } + } + } + + Row( + modifier = + Modifier + .padding(10.dp) + .clip(shape = CutCornerShape(20, 20, 20, 20)) + .border( + 5.dp, + MaterialTheme.colorScheme.mediumImportanceLink, + CutCornerShape(20), + ) + .background(backgroundFromImage.first), + ) { + RenderBadge( + image, + name, + backgroundFromImage.second, + description, + ) { + if (imageResult == null) { + imageResult = it.result + } + } + } +} + +@Composable +private fun RenderBadge( + image: String?, + name: String?, + backgroundFromImage: Color, + description: String?, + onSuccess: (AsyncImagePainter.State.Success) -> Unit, +) { + Column { + image.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.badge_award_image_for, + name ?: "", + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + onSuccess = onSuccess, + ) + } + + name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp), + color = backgroundFromImage, + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderBadgeAward( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (note.replyTo.isNullOrEmpty()) return + + val noteEvent = note.event as? BadgeAwardEvent ?: return + var awardees by remember { mutableStateOf>(listOf()) } + + Text(text = stringResource(R.string.award_granted_to)) + + LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } + + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + awardees.take(100).forEach { user -> + Row( + modifier = + Modifier + .size(size = Size35dp) + .clickable { nav("User/${user.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture( + baseUser = user, + accountViewModel = accountViewModel, + size = Size35dp, + ) + } + } + + if (awardees.size > 100) { + Text(" and ${awardees.size - 100} others", maxLines = 1) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt new file mode 100644 index 000000000..6d586ca52 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.ClassifiedsEvent + +@Composable +fun RenderClassifieds( + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } + + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + Row { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + price?.let { + val priceTag = + remember(noteEvent) { + val newAmount = + price.amount.toBigDecimalOrNull()?.let { showAmount(it) } + ?: price.amount + + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = + remember { + Modifier + .clip(SmallBorder) + .padding(start = 5.dp) + }, + ) + } + } + + if (summary != null || location != null) { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt new file mode 100644 index 000000000..d3d29c557 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -0,0 +1,394 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.Participant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.util.Locale + +@Composable +fun CommunityHeader( + baseNote: AddressableNote, + showBottomDiviser: Boolean, + sendToCommunity: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.Center, + modifier = + Modifier.clickable { + if (sendToCommunity) { + routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortCommunityHeader( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (expanded.value) { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader( + baseNote = baseNote, + lineModifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (showBottomDiviser) { + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +fun LongCommunityHeader( + baseNote: AddressableNote, + lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + + Row( + lineModifier, + ) { + val rulesLabel = stringResource(id = R.string.rules) + val summary = + remember(noteState) { + val subject = noteEvent.subject()?.ifEmpty { null } + val body = noteEvent.description()?.ifBlank { null } + val rules = noteEvent.rules()?.ifBlank { null } + + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { + if (rules == null) { + "### $subject\n$body" + } else { + "### $subject\n$body\n\n### $rulesLabel\n\n$rules" + } + } else { + if (rules == null) { + body + } else { + "$body\n\n$rulesLabel\n$rules" + } + } + } + + Column( + Modifier.weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.community_no_descriptor), + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + id = baseNote.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (summary != null && noteEvent.hasHashtags()) { + DisplayUncitedHashtags( + hashtags = remember(key1 = noteEvent) { noteEvent.hashtags().toImmutableList() }, + eventContent = summary, + nav = nav, + ) + } + } + + Column { + Row { + Spacer(DoubleHorzSpacer) + LongCommunityActionOptions(baseNote, accountViewModel, nav) + } + } + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = noteState) { + val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + + if (participants != null) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) + } + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) + MoreOptionsButton(baseNote, null, accountViewModel, nav) + } +} + +@Composable +fun ShortCommunityHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + Row(verticalAlignment = Alignment.CenterVertically) { + noteEvent.image()?.let { + RobohashFallbackAsyncImage( + robot = baseNote.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .height(Size35dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(noteState) { noteEvent.dTag() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ShortCommunityActionOptions(baseNote, accountViewModel, nav) + } + } +} + +@Composable +private fun ShortCommunityActionOptions( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (!isFollowing) { + Spacer(modifier = StdHorzSpacer) + JoinCommunityButton(accountViewModel, note, nav) + } + } +} + +@Composable +private fun LongCommunityActionOptions( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveCommunityButton(accountViewModel, note, nav) + } + } +} + +@Composable +fun WatchAddressableNoteFollows( + note: AddressableNote, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, +) { + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } + .distinctUntilChanged() + } + .observeAsState(false) + + onFollowChanges(showFollowingMark) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt new file mode 100644 index 000000000..2e767e945 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -0,0 +1,309 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.elements.AddButton +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.RemoveButton +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.EmojiPackEvent +import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.events.EmojiUrl +import com.vitorpamplona.quartz.events.WikiNoteEvent + +@Composable +fun RenderWikiContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? WikiNoteEvent ?: return + + WikiNoteHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +private fun WikiNoteHeader( + noteEvent: WikiNoteEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val image = remember(noteEvent) { noteEvent.image() } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = + remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +public fun RenderEmojiPack( + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + val noteEvent by + baseNote + .live() + .metadata + .map { it.note.event } + .distinctUntilChanged() + .observeAsState(baseNote.event) + + if (noteEvent == null || noteEvent !is EmojiPackEvent) return + + (noteEvent as? EmojiPackEvent)?.let { + RenderEmojiPack( + noteEvent = it, + baseNote = baseNote, + actionable = actionable, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +public fun RenderEmojiPack( + noteEvent: EmojiPackEvent, + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + var expanded by remember { mutableStateOf(false) } + + val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } + + val emojisToShow = + if (expanded) { + allEmojis + } else { + allEmojis.take(60) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(noteEvent) { "#${noteEvent.dTag()}" }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .weight(1F) + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + if (actionable) { + EmojiListOptions(accountViewModel, baseNote) + } + } + + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + emojisToShow.forEach { emoji -> + if (onClick != null) { + IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } else { + Box( + modifier = Size35Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } + } + } + + if (allEmojis.size > 60 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} + +@Composable +private fun EmojiListOptions( + accountViewModel: AccountViewModel, + emojiPackNote: Note, +) { + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { + it?.let { usersEmojiList -> + val hasAddedThis by + remember { + usersEmojiList + .live() + .metadata + .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } + .distinctUntilChanged() + } + .observeAsState() + + Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { + if (it != true) { + AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + } else { + RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt new file mode 100644 index 000000000..cda8eda50 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileHeaderEvent + +@Composable +fun FileHeaderDisplay( + note: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val event = (note.event as? FileHeaderEvent) ?: return + val fullUrl = event.url() ?: return + + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.content.ifEmpty { null } ?: event.alt() + val isImage = RichTextParser.isImageUrl(fullUrl) + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + ) + }, + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt new file mode 100644 index 000000000..d28d51d0f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaLocalImage +import com.vitorpamplona.amethyst.commons.MediaLocalVideo +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import java.io.File + +@Composable +fun FileStorageHeaderDisplay( + baseNote: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + val dataEventId = eventHeader.dataEventId() ?: return + + LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> + if (contentNote != null) { + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) + } + } +} + +@Composable +private fun ObserverAndRenderNIP95( + header: Note, + content: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return + + val appContext = LocalContext.current.applicationContext + + val noteState by content.live().metadata.observeAsState() + + val content by + remember(noteState) { + // Creates a new object when the event arrives to force an update of the image. + val note = noteState?.note + val uri = header.toNostrUri() + val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } + val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() + val description = eventHeader.alt() ?: eventHeader.content + val mimeType = eventHeader.mimeType() + + val newContent = + if (mimeType?.startsWith("image") == true) { + MediaLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + blurhash = blurHash, + isVerified = true, + uri = uri, + ) + } else { + MediaLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri, + authorName = header.author?.toBestDisplayName(), + ) + } + + mutableStateOf(newContent) + } + + Crossfade(targetState = content) { + if (it != null) { + SensitivityWarning(note = header, accountViewModel = accountViewModel) { + ZoomableContentView( + content = it, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt new file mode 100644 index 000000000..9be1ef012 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -0,0 +1,386 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.GitRepositoryEvent +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderGitPatchEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitPatchEvent ?: return + + RenderGitPatchEvent( + event, + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderShortRepositoryHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return + + Column( + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + ) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + noteEvent.description()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RenderGitPatchEvent( + noteEvent: GitPatchEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val repository = remember(noteEvent) { noteEvent.repository() } + + if (repository != null) { + LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + Spacer(modifier = DoubleVertSpacer) + } + } + } + + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} + +@Composable +fun RenderGitIssueEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitIssueEvent ?: return + + RenderGitIssueEvent( + event, + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderGitIssueEvent( + noteEvent: GitIssueEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val repository = remember(noteEvent) { noteEvent.repository() } + + if (repository != null) { + LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + Spacer(modifier = DoubleVertSpacer) + } + } + } + + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} + +@Composable +fun RenderGitRepositoryEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitRepositoryEvent ?: return + + RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav) +} + +@Composable +private fun RenderGitRepositoryEvent( + noteEvent: GitRepositoryEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + val summary = remember(noteEvent) { noteEvent.description() } + val web = remember(noteEvent) { noteEvent.web() } + val clone = remember(noteEvent) { noteEvent.clone() } + + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), + ) { + Column { + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + summary?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + web?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_web_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + clone?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_clone_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt new file mode 100644 index 000000000..db227ced8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -0,0 +1,202 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.LongTextNoteEvent +import java.net.URL + +@Composable +fun RenderHighlight( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } + val author = remember { (note.event as? HighlightEvent)?.author() } + val url = remember { (note.event as? HighlightEvent)?.inUrl() } + val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } + + DisplayHighlight( + highlight = quote, + authorHex = author, + url = url, + postAddress = postHex, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun DisplayHighlight( + highlight: String, + authorHex: String?, + url: String?, + postAddress: ATag?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val quote = + remember { + highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + } + + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + remember { Modifier.fillMaxWidth() }, + EmptyTagList, + backgroundColor, + id = quote, + accountViewModel, + nav, + ) + + DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DisplayQuoteAuthor( + authorHex: String, + url: String?, + postAddress: ATag?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } + + if (userBase == null) { + LaunchedEffect(Unit) { + accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> + userBase = newUserBase + } + } + } + + val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + verticalArrangement = Arrangement.Center, + ) { + userBase?.let { userBase -> + val userMetadata by userBase.live().userMetadataInfo.observeAsState() + + CreateClickableTextWithEmoji( + clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: userBase.pubkeyDisplayHex(), + maxLines = 1, + route = "User/${userBase.pubkeyHex}", + nav = nav, + tags = userMetadata?.tags, + ) + } + + url?.let { url -> LoadAndDisplayUrl(url) } + + postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } + } +} + +@Composable +fun LoadAndDisplayUrl(url: String) { + val validatedUrl = + remember { + try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + } + + validatedUrl?.host?.let { host -> + Text(remember { "-" }, maxLines = 1) + ClickableUrl(urlText = host, url = url) + } +} + +@Composable +private fun LoadAndDisplayPost( + postAddress: ATag, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadAddressableNote(aTag = postAddress, accountViewModel) { + it?.let { note -> + val noteEvent by + note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) + + val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } + + title?.let { + Text(remember { "-" }, maxLines = 1) + ClickableText( + text = AnnotatedString(title), + onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt new file mode 100644 index 000000000..36f25aaed --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -0,0 +1,217 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.LiveActivitiesEvent +import com.vitorpamplona.quartz.events.Participant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.Locale + +@Composable +fun RenderLiveActivityEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Row(modifier = Modifier.padding(top = 5.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) + } + } +} + +@Composable +fun RenderLiveActivityEventInner( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + + val eventUpdates by baseNote.live().metadata.observeAsState() + + val media = remember(eventUpdates) { noteEvent.streaming() } + val cover = remember(eventUpdates) { noteEvent.image() } + val subject = remember(eventUpdates) { noteEvent.title() } + val content = remember(eventUpdates) { noteEvent.summary() } + val participants = remember(eventUpdates) { noteEvent.participants() } + val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(vertical = 5.dp) + .fillMaxWidth(), + ) { + subject?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = StdHorzSpacer) + + Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { + when (it) { + LiveActivitiesEvent.STATUS_LIVE -> { + media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } + } + + LiveActivitiesEvent.STATUS_PLANNED -> { + ScheduledFlag(starts) + } + } + } + } + + var participantUsers by remember { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = eventUpdates) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + media?.let { media -> + if (status == LiveActivitiesEvent.STATUS_LIVE) { + CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> + if (isOnline) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + title = subject, + artworkUri = cover, + authorName = baseNote.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = "nostr:${baseNote.toNEvent()}", + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(10.dp) + .height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_is_offline), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else if (status == LiveActivitiesEvent.STATUS_ENDED) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(10.dp) + .height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_has_ended), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(vertical = 5.dp) + .clickable { nav("User/${it.second.pubkeyHex}") }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(StdHorzSpacer) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(StdHorzSpacer) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt new file mode 100644 index 000000000..f4584efbe --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.LongTextNoteEvent + +@Composable +fun RenderLongFormContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? LongTextNoteEvent ?: return + + LongFormHeader(noteEvent, note, accountViewModel) +} + +@Composable +private fun LongFormHeader( + noteEvent: LongTextNoteEvent, + note: Note, + accountViewModel: AccountViewModel, +) { + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = + remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt new file mode 100644 index 000000000..0231b8d60 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Bundle +import com.vitorpamplona.amethyst.model.FhirElementDatabase +import com.vitorpamplona.amethyst.model.LensSpecification +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.Patient +import com.vitorpamplona.amethyst.model.Practitioner +import com.vitorpamplona.amethyst.model.Resource +import com.vitorpamplona.amethyst.model.VisionPrescription +import com.vitorpamplona.amethyst.model.findReferenceInDb +import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.text.DecimalFormat +import java.text.NumberFormat + +@Preview +@Composable +fun RenderEyeGlassesPrescriptionPreview() { + val prescriptionEvent = + Event.fromJson( + "{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}", + ) as FhirResourceEvent + + RenderFhirResource(prescriptionEvent) +} + +@Composable +fun RenderFhirResource( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? FhirResourceEvent ?: return + + RenderFhirResource(event) +} + +@Composable +fun RenderFhirResource(event: FhirResourceEvent) { + val state by produceState(initialValue = FhirElementDatabase(), key1 = event) { + withContext(Dispatchers.Default) { + parseResourceBundleOrNull(event.content)?.let { + value = it + } + } + } + + state.baseResource?.let { resource -> + when (resource) { + is Bundle -> { + val vision = resource.entry.filterIsInstance(VisionPrescription::class.java) + + vision.firstOrNull()?.let { + RenderEyeGlassesPrescription(it, state.localDb) + } + } + is VisionPrescription -> { + RenderEyeGlassesPrescription(resource, state.localDb) + } + else -> { + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescription( + visionPrescription: VisionPrescription, + db: ImmutableMap, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp), + ) { + val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" } + val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" } + + Text( + "Eyeglasses Prescription", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(StdVertSpacer) + + visionPrescription.patient?.reference?.let { + val patient = findReferenceInDb(it, db) as? Patient + + patient?.name?.firstOrNull()?.assembleName()?.let { + Text( + text = "Patient: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + } + visionPrescription.status?.let { + Text( + text = "Status: ${it.capitalize()}", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + + Spacer(DoubleVertSpacer) + + RenderEyeGlassesPrescriptionHeaderRow() + HorizontalDivider(thickness = DividerThickness) + + rightEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + leftEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + visionPrescription.prescriber?.reference?.let { + val practitioner = findReferenceInDb(it, db) as? Practitioner + + practitioner?.name?.firstOrNull()?.assembleName()?.let { + Spacer(DoubleVertSpacer) + Text( + text = "Signed by: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Right, + ) + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescriptionHeaderRow() { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Eye", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Sph", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Cyl", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Axis", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Add", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +@Composable +fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val numberFormat = DecimalFormat("##.00") + val integerFormat = DecimalFormat("###") + + Text( + text = data.eye?.capitalize() ?: "Unknown", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.sphere, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.cylinder, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.axis, integerFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.add, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +fun formatOrBlank( + amount: Double?, + numberFormat: NumberFormat, +): String { + if (amount == null) return "" + if (Math.abs(amount) < 0.01) return "" + return numberFormat.format(amount) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt new file mode 100644 index 000000000..a5d6c2ae0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.PeopleListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplayPeopleList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? PeopleListEvent ?: return + + var members by remember { mutableStateOf>(persistentListOf()) } + + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + members + } else { + members.take(3) + } + + val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + LaunchedEffect(Unit) { + accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { + members = it + } + } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Row(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + overallModifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt new file mode 100644 index 000000000..4ab4876bb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.PinIcon +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PinListEvent + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderPinListEvent( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? PinListEvent ?: return + + val pins by remember { mutableStateOf(noteEvent.pins()) } + + var expanded by remember { mutableStateOf(false) } + + val pinsToShow = + if (expanded) { + pins + } else { + pins.take(3) + } + + Text( + text = "#${noteEvent.dTag()}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + pinsToShow.forEach { pin -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + PinIcon( + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), + ) + + Spacer(modifier = Modifier.width(5.dp)) + + TranslatableRichTextViewer( + content = pin, + canPreview = true, + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = baseNote.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (pins.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt new file mode 100644 index 000000000..f9bd72547 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.PollNote +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PollNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderPoll( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = noteEvent.content() + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = remember { Modifier.fillMaxWidth() }, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = remember { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt new file mode 100644 index 000000000..84fbc70de --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderPrivateMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? PrivateDmEvent ?: return + + val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + if (withMe) { + LoadDecryptedContent(note, accountViewModel) { eventContent -> + val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } + val isAuthorTheLoggedUser = + remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } + + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = + remember(note.event?.id()) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } + } else { + val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" + + TranslatableRichTextViewer( + stringResource( + id = R.string.private_conversation_notification, + "@${note.author?.pubkeyNpub()}", + "@$recipient", + ), + canPreview = !makeItShort, + Modifier.fillMaxWidth(), + EmptyTagList, + backgroundColor, + id = note.idHex, + accountViewModel, + nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt new file mode 100644 index 000000000..d42515700 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun RenderReaction( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Reposts have trash in their contents. + val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: "" + + Text( + text = refactorReactionText, + maxLines = 1, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt new file mode 100644 index 000000000..ea134909e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.actions.NewRelayListView +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.AddRelayButton +import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.RelaySetEvent +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun DisplayRelaySet( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? RelaySetEvent ?: return + + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + ) + } + + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + relays + } else { + relays.take(3) + } + + val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + val relayDescription by remember { derivedStateOf { noteEvent.description() } } + + Text( + text = relayListName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + relayDescription?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + color = Color.Gray, + ) + } + + Box { + Column(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { relay -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = relay.displayUrl, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(start = 10.dp, bottom = 5.dp) + .weight(1f), + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + RelayOptionsAction(relay.url, accountViewModel, nav) + } + } + } + } + + if (relays.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} + +@Composable +private fun RelayOptionsAction( + relay: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() + val isCurrentlyOnTheUsersList by + remember(userStateRelayInfo) { + derivedStateOf { + userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true + } + } + + var wantsToAddRelay by remember { mutableStateOf("") } + + if (wantsToAddRelay.isNotEmpty()) { + NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) + } + + if (isCurrentlyOnTheUsersList) { + AddRelayButton { wantsToAddRelay = relay } + } else { + RemoveRelayButton { wantsToAddRelay = relay } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt new file mode 100644 index 000000000..d9e3c4197 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent + +@Composable +fun RenderPostApproval( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (note.replyTo.isNullOrEmpty()) return + + val noteEvent = note.event as? CommunityPostApprovalEvent ?: return + + Column(Modifier.fillMaxWidth()) { + noteEvent.communities().forEach { + LoadAddressableNote(it, accountViewModel) { + it?.let { + NoteCompose( + it, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + Text( + text = stringResource(id = R.string.community_approved_posts), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + note.replyTo?.forEach { + NoteCompose( + it, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + isQuotedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt new file mode 100644 index 000000000..7075af94c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.ReportEvent + +@Composable +fun RenderReport( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? ReportEvent ?: return + + val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + + val reportType = + base + .map { + when (it.reportType) { + ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) + ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) + ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) + ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) + ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) + ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + ReportEvent.ReportType.OTHER -> stringResource(R.string.other) + } + } + .toSet() + .joinToString(", ") + + val content = + remember { + reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") + } + + TranslatableRichTextViewer( + content = content, + canPreview = true, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + + note.replyTo?.lastOrNull()?.let { + NoteCompose( + baseNote = it, + isQuotedNote = true, + modifier = + Modifier + .padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + unPackReply = false, + makeItShort = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt new file mode 100644 index 000000000..538e4f6a3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderTextEvent( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadDecryptedContent( + note, + accountViewModel, + ) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val newBody = + if (editState.value is GenericLoadable.Loaded) { + val state = + (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow + state?.value?.event?.content() ?: body + } else { + body + } + + if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { + "### $subject\n$newBody" + } else { + newBody + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt new file mode 100644 index 000000000..2920ae174 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -0,0 +1,280 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.NoteBody +import com.vitorpamplona.amethyst.ui.note.observeEdits +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun RenderTextModificationEvent( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? TextNoteModificationEvent ?: return + val noteAuthor = note.author ?: return + + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + val isAuthorTheLoggedUser = + remember { + val authorOfTheOriginalNote = + noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it)?.author } + + mutableStateOf(accountViewModel.isLoggedUser(authorOfTheOriginalNote)) + } + + noteEvent.editedNote()?.let { + LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseOriginalNote -> + baseOriginalNote?.let { + } + } + } + + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column(Modifier.fillMaxWidth().padding(Size10dp)) { + Text( + text = stringResource(id = R.string.proposal_to_edit), + style = + TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ), + ) + + Spacer(modifier = StdVertSpacer) + + noteEvent.summary()?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + + noteEvent.editedNote()?.let { + LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseNote -> + baseNote?.let { + val noteState by baseNote.live().metadata.observeAsState() + + val editStateOriginalNote = + observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + + val editState = + remember(note) { + derivedStateOf { + val loadable = + editStateOriginalNote.value as? GenericLoadable.Loaded + + val state = EditState() + + val latestChangeByAuthor = + if (loadable != null && loadable.loaded.hasModificationsToShow()) { + loadable.loaded.latestBefore(note.createdAt()) + } else { + null + } + + state.updateModifications( + listOfNotNull( + latestChangeByAuthor, + note, + ), + ) + + GenericLoadable.Loaded(state) + } + } + + LaunchedEffect(key1 = noteState) { + val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) + + if (isAuthorTheLoggedUser.value != newAuthor) { + isAuthorTheLoggedUser.value = newAuthor + } + } + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp) + .clickable { + routeFor( + baseNote, + accountViewModel.userProfile(), + )?.let { nav(it) } + }, + ) { + NoteBody( + baseNote = baseNote, + showAuthorPicture = true, + unPackReply = false, + makeItShort = false, + canPreview = true, + showSecondRow = false, + backgroundColor = backgroundColor, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (wantsToEditPost.value) { + EditPostView( + onClose = { + wantsToEditPost.value = false + }, + edit = baseNote, + versionLookingAt = note, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } + + if (isAuthorTheLoggedUser.value) { + Spacer(modifier = StdVertSpacer) + + Button( + onClick = { wantsToEditPost.value = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = R.string.accept_the_suggestion)) + } + } + } + } +} + +@Stable +class EditState() { + private var modificationsList: List = persistentListOf() + private var modificationToShowIndex: Int = -1 + + val modificationToShow: MutableState = mutableStateOf(null) + val showingVersion: MutableState = mutableStateOf(0) + + fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty() + + fun isOriginal(): Boolean = modificationToShowIndex < 0 + + fun isLatest(): Boolean = modificationToShowIndex == modificationsList.lastIndex + + fun originalVersionId() = 0 + + fun lastVersionId() = modificationsList.size + + fun versionId() = modificationToShowIndex + 1 + + fun latest() = modificationsList.lastOrNull() + + fun latestBefore(createdAt: Long?): Note? { + if (createdAt == null) return latest() + return modificationsList.lastOrNull { (it.createdAt() ?: Long.MAX_VALUE) < createdAt } + } + + fun nextModification() { + if (modificationToShowIndex < 0) { + modificationToShowIndex = 0 + modificationToShow.value = modificationsList.getOrNull(0) + } else { + modificationToShowIndex++ + if (modificationToShowIndex >= modificationsList.size) { + modificationToShowIndex = -1 + modificationToShow.value = null + } else { + modificationToShow.value = modificationsList.getOrNull(modificationToShowIndex) + } + } + + showingVersion.value = versionId() + } + + fun updateModifications(newModifications: List) { + if (modificationsList != newModifications) { + modificationsList = newModifications + + if (newModifications.isEmpty()) { + modificationToShow.value = null + modificationToShowIndex = -1 + } else { + modificationToShowIndex = newModifications.lastIndex + modificationToShow.value = newModifications.last() + } + } + + showingVersion.value = versionId() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt new file mode 100644 index 000000000..4e532de82 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun VideoDisplay( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return + + val title = event.title() + val summary = event.content.ifBlank { null }?.takeIf { title != it } + val image = event.thumb() ?: event.image() + val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.content.ifBlank { null } ?: event.alt() + val isImage = RichTextParser.isImageUrl(fullUrl) + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = event.thumb() ?: event.image(), + ) + }, + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isYouTube) { + val uri = LocalUriHandler.current + Row( + modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, + ) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = MaterialTheme.colorScheme.imageModifier, + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + } else { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + + title?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + ) + } + + summary?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (event.hasHashtags()) { + Row( + Modifier.fillMaxWidth(), + ) { + DisplayUncitedHashtags( + remember(event) { event.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 2d24b634c..8bea5062e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable -fun RefresheableCardView( +fun RefreshableCardView( viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index bf5720b83..89c5c6b52 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -54,7 +54,7 @@ fun RefreshingChatroomFeedView( scrollStateKey: String? = null, enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { + RefresheableBox(viewModel, enablePullRefresh) { SaveableFeedState(viewModel, scrollStateKey) { listState -> RenderChatroomFeedView( viewModel, @@ -150,7 +150,7 @@ fun NewSubject(note: Note) { @Composable fun NewSubject(newSubject: String) { Row(verticalAlignment = Alignment.CenterVertically) { - Divider( + HorizontalDivider( modifier = Modifier.weight(1f), ) Text( @@ -159,7 +159,7 @@ fun NewSubject(newSubject: String) { fontSize = Font14SP, modifier = HalfPadding, ) - Divider( + HorizontalDivider( modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index ffa1a6f7e..43fa223fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -45,7 +45,7 @@ fun ChatroomListFeedView( nav: (String) -> Unit, markAsRead: MutableState, ) { - RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) } + RefresheableBox(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt index ea9d5ac64..4061e144c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt @@ -27,8 +27,10 @@ import kotlinx.collections.immutable.ImmutableList sealed class FeedState { object Loading : FeedState() - class Loaded(val feed: MutableState>, val showHidden: MutableState) : - FeedState() + class Loaded( + val feed: MutableState>, + val showHidden: MutableState, + ) : FeedState() object Empty : FeedState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index f1064990c..4330347e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -56,7 +57,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import kotlin.time.ExperimentalTime +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable fun RefresheableFeedView( @@ -67,13 +68,15 @@ fun RefresheableFeedView( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { - SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav) + RefresheableBox(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead) + } } } @Composable -fun RefresheableView( +fun RefresheableBox( viewModel: InvalidatableViewModel, enablePullRefresh: Boolean = true, content: @Composable () -> Unit, @@ -108,19 +111,6 @@ fun RefresheableView( } } -@Composable -private fun SaveableFeedState( - viewModel: FeedViewModel, - routeForLastRead: String?, - scrollStateKey: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - SaveableFeedState(viewModel, scrollStateKey) { listState -> - RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) - } -} - @Composable fun SaveableFeedState( viewModel: FeedViewModel, @@ -158,12 +148,16 @@ fun SaveableGridFeedState( } @Composable -private fun RenderFeed( +fun RenderFeedState( viewModel: FeedViewModel, accountViewModel: AccountViewModel, listState: LazyListState, nav: (String) -> Unit, routeForLastRead: String?, + onLoaded: @Composable (FeedState.Loaded) -> Unit = { FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav) }, + onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } }, + onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } }, + onLoading: @Composable () -> Unit = { LoadingFeed() }, ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() @@ -172,24 +166,10 @@ private fun RenderFeed( animationSpec = tween(durationMillis = 100), ) { state -> when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - FeedLoaded( - state = state, - listState = listState, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav, - ) - } - is FeedState.Loading -> { - LoadingFeed() - } + is FeedState.Empty -> onEmpty() + is FeedState.FeedError -> onError(state.errorMessage) + is FeedState.Loaded -> onLoaded(state) + is FeedState.Loading -> onLoading() } } } @@ -224,7 +204,7 @@ private fun WatchScrollToTop( } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) +@OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedLoaded( state: FeedState.Loaded, @@ -251,6 +231,20 @@ private fun FeedLoaded( nav = nav, ) } + + /*var see by + remember { + mutableStateOf(false) + } + + if (see) { + } else { + Row(defaultModifier) { + Button(onClick = { see = true }) { + Text("Show") + } + } + }*/ } } } @@ -277,6 +271,7 @@ fun FeedError( verticalArrangement = Arrangement.Center, ) { Text("${stringResource(R.string.error_loading_replies)} $errorMessage") + Spacer(modifier = StdVertSpacer) Button( modifier = Modifier.align(Alignment.CenterHorizontally), onClick = onRefresh, @@ -294,6 +289,7 @@ fun FeedEmpty(onRefresh: () -> Unit) { verticalArrangement = Arrangement.Center, ) { Text(stringResource(R.string.feed_is_empty)) + Spacer(modifier = StdVertSpacer) OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index d2610f923..b11e369ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -328,7 +328,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : _feedContent.update { FeedState.Empty } } else if (currentState is FeedState.Loaded) { // updates the current list - currentState.showHidden.value = localFilter.showHiddenKey() + if (currentState.showHidden.value != localFilter.showHiddenKey()) { + currentState.showHidden.value = localFilter.showHiddenKey() + } currentState.feed.value = notes } else { _feedContent.update { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt index f4c355fdc..e1f3a41d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt @@ -45,7 +45,7 @@ fun RefreshingFeedStringFeedView( enablePullRefresh: Boolean = true, inner: @Composable (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) } + RefresheableBox(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 2d3153de5..9ca328593 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -40,7 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -85,51 +85,57 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InlineCarrousel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayPoW -import com.vitorpamplona.amethyst.ui.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits -import com.vitorpamplona.amethyst.ui.elements.Reward -import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage -import com.vitorpamplona.amethyst.ui.note.AudioHeader -import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader -import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote -import com.vitorpamplona.amethyst.ui.note.CreateImageHeader -import com.vitorpamplona.amethyst.ui.note.DisplayHighlight -import com.vitorpamplona.amethyst.ui.note.DisplayLocation -import com.vitorpamplona.amethyst.ui.note.DisplayOts -import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList -import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet -import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay -import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow -import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition -import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack -import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent -import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent -import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent -import com.vitorpamplona.amethyst.ui.note.RenderPoll -import com.vitorpamplona.amethyst.ui.note.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.RenderRepost -import com.vitorpamplona.amethyst.ui.note.RenderTextEvent -import com.vitorpamplona.amethyst.ui.note.VideoDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation +import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts +import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW +import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits +import com.vitorpamplona.amethyst.ui.note.elements.ForkInformationRow +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.elements.Reward +import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.types.AudioHeader +import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader +import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayHighlight +import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource +import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThinSendButton @@ -156,9 +162,11 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitPatchEvent import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent @@ -168,6 +176,7 @@ import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RepostEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList @@ -377,6 +386,8 @@ fun NoteMaster( .fillMaxWidth() .padding(top = 10.dp), ) { + val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + Row( modifier = Modifier @@ -407,6 +418,12 @@ fun NoteMaster( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.let { + DisplayEditStatus(it) + } + } + Text( timeAgo(note.createdAt(), context = context), color = MaterialTheme.colorScheme.placeholderText, @@ -419,12 +436,12 @@ fun NoteMaster( ) { Icon( imageVector = Icons.Default.MoreVert, - null, + contentDescription = stringResource(id = R.string.more_options), modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) - NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) + NoteDropDownMenu(baseNote, moreActionsExpanded, editState, accountViewModel, nav) } } @@ -532,10 +549,14 @@ fun NoteMaster( accountViewModel, nav, ) + } else if (noteEvent is FhirResourceEvent) { + RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) } else if (noteEvent is GitPatchEvent) { RenderGitPatchEvent(baseNote, false, true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is GitIssueEvent) { + RenderGitIssueEvent(baseNote, false, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { @@ -552,6 +573,15 @@ fun NoteMaster( ) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is TextNoteModificationEvent) { + RenderTextModificationEvent( + note = baseNote, + makeItShort = false, + canPreview = true, + backgroundColor, + accountViewModel, + nav, + ) } else if (noteEvent is PollNoteEvent) { val canPreview = note.author == account.userProfile() || @@ -577,6 +607,7 @@ fun NoteMaster( false, canPreview, backgroundColor, + editState, accountViewModel, nav, ) @@ -591,9 +622,9 @@ fun NoteMaster( DisplayZapSplits(noteEvent, false, accountViewModel, nav) } - ReactionsRow(note, true, accountViewModel, nav) + ReactionsRow(note, true, editState, accountViewModel, nav) - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -633,7 +664,7 @@ private fun RenderClassifiedsReaderForThread( ) } } else { - CreateImageHeader(note, accountViewModel) + DefaultImageHeader(note, accountViewModel) } Row( @@ -829,6 +860,11 @@ private fun RenderWikiHeaderForThreadPreview() { val accountViewModel = mockAccountViewModel() val nav: (String) -> Unit = {} + val editState = + remember { + mutableStateOf(GenericLoadable.Empty()) + } + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null) @@ -851,6 +887,7 @@ private fun RenderWikiHeaderForThreadPreview() { false, true, backgroundColor, + editState, accountViewModel, nav, ) @@ -870,6 +907,7 @@ private fun RenderWikiHeaderForThreadPreview() { false, true, backgroundColor, + editState, accountViewModel, nav, ) @@ -934,24 +972,3 @@ private fun RenderWikiHeaderForThread( } } } - -@Composable -fun ForkInformationRow( - originalVersion: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by originalVersion.live().metadata.observeAsState() - val note = noteState?.note ?: return - val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } - - if (route != null) { - Row(modifier) { - Text(stringResource(id = R.string.forked_from)) - Spacer(modifier = StdHorzSpacer) - LoadAndDisplayUser(author, route, nav) - } - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 6fc453d92..bc52f5c4a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -39,7 +39,7 @@ fun RefreshingFeedUserFeedView( nav: (String) -> Unit, enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) } + RefresheableBox(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6b6969f78..88ec1d47b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -932,6 +932,17 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } + fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) + + suspend fun findModificationEventsForNote( + note: Note, + onResult: (List) -> Unit, + ) { + withContext(Dispatchers.IO) { + onResult(LocalCache.findLatestModificationForNote(note)) + } + } + private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { return LocalCache.checkGetOrCreateChannel(key) } @@ -1003,6 +1014,13 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } + fun loadNEmbedIfNeeded(nembed: Event) { + val baseNote = LocalCache.getNoteIfExists(nembed.id) + if (baseNote?.event == null) { + LocalCache.verifyAndConsume(nembed, null) + } + } + suspend fun parseNIP19( str: String, onNote: (LoadedBechLink) -> Unit, @@ -1018,9 +1036,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View is Nip19Bech32.Note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } is Nip19Bech32.NEvent -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } is Nip19Bech32.NEmbed -> { - if (LocalCache.getNoteIfExists(parsed.event.id) == null) { - LocalCache.verifyAndConsume(parsed.event, null) - } + loadNEmbedIfNeeded(parsed.event) LocalCache.checkGetOrCreateNote(parsed.event.id)?.let { note -> returningNote = note diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 0a586fd87..7286cfc0b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -50,10 +50,10 @@ import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -121,18 +121,18 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel @@ -640,7 +640,7 @@ fun ChannelHeader( } if (showBottomDiviser) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -710,8 +710,8 @@ fun ShortChannelHeader( nav: (String) -> Unit, showFlag: Boolean, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { channelState.value?.channel } ?: return + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel ?: return val automaticallyShowProfilePicture = remember { @@ -782,8 +782,8 @@ fun LongChannelHeader( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { channelState.value?.channel } ?: return + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel ?: return Row( lineModifier, @@ -811,6 +811,7 @@ fun LongChannelHeader( canPreview = false, tags = tags, backgroundColor = background, + id = baseChannel.idHex, accountViewModel = accountViewModel, nav = nav, ) @@ -865,7 +866,7 @@ fun LongChannelHeader( ) Spacer(DoubleHorzSpacer) NormalTimeAgo(note, remember { Modifier.weight(1f) }) - MoreOptionsButton(note, accountViewModel) + MoreOptionsButton(note, null, accountViewModel, nav) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 94d4fa306..be3c1f6f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -33,9 +33,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -170,7 +170,7 @@ fun ChatroomListTwoPane( Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { ChannelFabColumn(accountViewModel, nav) } - Divider( + HorizontalDivider( modifier = Modifier.fillMaxHeight() // fill the max height .width(DividerThickness), @@ -269,7 +269,7 @@ fun ChatroomListScreenOnlyList( ) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = null, + contentDescription = stringResource(id = R.string.more_options), tint = MaterialTheme.colorScheme.placeholderText, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 41df0fd51..048738d96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -45,8 +45,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Send import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -69,7 +68,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -99,6 +97,8 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject import com.vitorpamplona.amethyst.ui.note.DisplayUserSetAsSubject +import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff +import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog @@ -106,7 +106,6 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier @@ -115,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey @@ -442,17 +442,19 @@ fun PrivateMessageEditFieldRow( }, ) { if (channelScreenModel.nip24) { - Icon( - painter = painterResource(id = R.drawable.incognito), - null, - modifier = Modifier.padding(top = 2.dp).size(18.dp), + IncognitoIconOn( + modifier = + Modifier + .padding(top = 2.dp) + .size(18.dp), tint = MaterialTheme.colorScheme.primary, ) } else { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - null, - modifier = Modifier.padding(top = 2.dp).size(18.dp), + IncognitoIconOff( + modifier = + Modifier + .padding(top = 2.dp) + .size(18.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -535,7 +537,7 @@ fun ThinSendButton( ) { Icon( imageVector = Icons.Default.Send, - null, + contentDescription = stringResource(id = R.string.accessibility_send), modifier = Size20Modifier, ) } @@ -601,7 +603,7 @@ fun ChatroomHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -641,7 +643,7 @@ fun GroupChatroomHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -661,11 +663,7 @@ private fun EditRoomSubjectButton( Button( modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), onClick = { wantsToPost = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + contentPadding = ZeroPadding, ) { Icon( tint = Color.White, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index e42c2fe6c..a5f134cf1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -70,7 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys @@ -197,7 +196,7 @@ private fun DiscoverPages( } HorizontalPager(state = pagerState) { page -> - RefresheableView(tabs[page].viewModel, true) { + RefresheableBox(tabs[page].viewModel, true) { if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> @@ -232,7 +231,7 @@ private fun RenderDiscoverFeed( viewModel: FeedViewModel, routeForLastRead: String?, forceEventKind: Int?, - listState: ScrollableState, + listState: LazyGridState, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -251,25 +250,54 @@ private fun RenderDiscoverFeed( FeedError(state.errorMessage) { viewModel.invalidateData() } } is FeedState.Loaded -> { - if (listState is LazyGridState) { - DiscoverFeedColumnsLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav, - ) - } else if (listState is LazyListState) { - DiscoverFeedLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav, - ) - } + DiscoverFeedColumnsLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@Composable +private fun RenderDiscoverFeed( + viewModel: FeedViewModel, + routeForLastRead: String?, + forceEventKind: Int?, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + DiscoverFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) } is FeedState.Loading -> { LoadingFeed() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt index 33b0dc61c..725154bbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -27,36 +27,30 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrGeohashDataSource -import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil +import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.StdPadding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun GeoHashScreen( @@ -163,7 +157,7 @@ fun GeoHashHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -174,27 +168,13 @@ fun DislayGeoTagHeader( geohash: String, modifier: Modifier, ) { - val context = LocalContext.current - - var cityName by remember(geohash) { mutableStateOf(geohash) } - - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { - null - } - if (newCityName != null && newCityName != cityName) { - cityName = "$newCityName ($geohash)" - } - } + LoadCityName(geohashStr = geohash) { cityName -> + Text( + cityName, + fontWeight = FontWeight.Bold, + modifier = modifier, + ) } - - Text( - cityName, - fontWeight = FontWeight.Bold, - modifier = modifier, - ) } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index de08249ff..2132d88ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -160,7 +160,7 @@ fun HashtagHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 1381ee22b..a2ff9649a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScrollableTabRow @@ -67,11 +67,11 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.elements.AddButton +import com.vitorpamplona.amethyst.ui.note.elements.AddButton import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHiddenWordsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.StringFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel @@ -177,7 +177,7 @@ fun HiddenUsersScreen( edgePadding = 8.dp, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, - divider = { Divider(thickness = DividerThickness) }, + divider = { HorizontalDivider(thickness = DividerThickness) }, ) { Tab( selected = pagerState.currentPage == 0, @@ -216,7 +216,7 @@ private fun HiddenWordsFeed( hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, accountViewModel: AccountViewModel, ) { - RefresheableView(hiddenWordsViewModel, false) { + RefresheableBox(hiddenWordsViewModel, false) { StringFeedView( hiddenWordsViewModel, post = { AddMuteWordTextField(accountViewModel) }, @@ -313,7 +313,7 @@ fun MutedWordHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 7773764f6..24c5a7ede 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -22,11 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -39,9 +43,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -54,10 +60,14 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys -import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox +import com.vitorpamplona.amethyst.ui.screen.RenderFeedState +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -181,7 +191,7 @@ private fun HomePages( } HorizontalPager(state = pagerState, userScrollEnabled = false) { page -> - RefresheableFeedView( + HomeFeeds( viewModel = tabs[page].viewModel, routeForLastRead = tabs[page].routeForLastRead, scrollStateKey = tabs[page].scrollStateKey, @@ -191,6 +201,51 @@ private fun HomePages( } } +@Composable +fun HomeFeeds( + viewModel: FeedViewModel, + routeForLastRead: String?, + enablePullRefresh: Boolean = true, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + RefresheableBox(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeedState( + viewModel = viewModel, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = routeForLastRead, + onEmpty = { HomeFeedEmpty { viewModel.invalidateData() } }, + ) + } + } +} + +@Preview +@Composable +fun HomeFeedEmptyPreview() { + ThemeComparisonRow( + onDark = { HomeFeedEmpty {} }, + onLight = { HomeFeedEmpty {} }, + ) +} + +@Composable +fun HomeFeedEmpty(onRefresh: () -> Unit) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.feed_is_empty)) + Spacer(modifier = StdVertSpacer) + OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + } +} + @Composable fun CheckIfUrlIsOnline( url: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 99269225e..2ba1c7532 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableCardView +import com.vitorpamplona.amethyst.ui.screen.RefreshableCardView import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -121,7 +121,7 @@ fun NotificationScreen( model = userReactionsStatsModel, ) - RefresheableCardView( + RefreshableCardView( viewModel = notifFeedViewModel, accountViewModel = accountViewModel, nav = nav, @@ -230,7 +230,7 @@ fun SummaryBar(model: UserReactionsViewModel) { } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 094682cbe..f1b118ed1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -57,9 +57,9 @@ import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -476,7 +476,7 @@ private fun RenderScreen( selectedTabIndex = pagerState.currentPage, edgePadding = 8.dp, modifier = tabRowModifier, - divider = { Divider(thickness = DividerThickness) }, + divider = { HorizontalDivider(thickness = DividerThickness) }, ) { CreateAndRenderTabs(baseUser, pagerState) } @@ -819,7 +819,7 @@ private fun ProfileHeader( DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) - Divider(modifier = Modifier.padding(top = 6.dp)) + HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) } } @@ -1103,6 +1103,7 @@ private fun DrawAdditionalInfo( canPreview = false, tags = EmptyTagList, backgroundColor = background, + id = it, accountViewModel = accountViewModel, nav = nav, ) @@ -1870,7 +1871,7 @@ fun UserProfileDropDownMenu( ) if (accountViewModel.userProfile() != user) { - Divider() + HorizontalDivider(thickness = DividerThickness) if (accountViewModel.account.isHidden(user)) { DropdownMenuItem( text = { Text(stringResource(R.string.unblock_user)) }, @@ -1888,7 +1889,7 @@ fun UserProfileDropDownMenu( }, ) } - Divider() + HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { Text(stringResource(id = R.string.report_spam_scam)) }, onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 69c921b61..4186d0720 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -33,8 +33,8 @@ import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Report import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -61,6 +61,7 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.WarningColor import com.vitorpamplona.quartz.events.ReportEvent import kotlinx.collections.immutable.toImmutableList @@ -126,7 +127,7 @@ fun ReportNoteDialog( ) SpacerH16() - Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness) SpacerH16() SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 57cc6bbca..437c0e12b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -36,8 +36,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -446,7 +446,7 @@ fun HashtagLine( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) @@ -481,7 +481,7 @@ fun UserLine( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 24ff01a66..59eb824e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -57,36 +57,38 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.BoostReaction -import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay -import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture -import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.RenderRelay import com.vitorpamplona.amethyst.ui.note.ReplyReaction import com.vitorpamplona.amethyst.ui.note.ViewCountReaction import com.vitorpamplona.amethyst.ui.note.WatchForReports import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.theme.Size35Modifier @@ -214,7 +216,7 @@ private fun LoadedState( WatchScrollToTop(videoFeedView, pagerState) - RefresheableView(viewModel = videoFeedView) { + RefresheableBox(viewModel = videoFeedView) { SlidingCarousel( state.feed, pagerState, @@ -363,12 +365,12 @@ private fun RenderAuthorInformation( ) { Row(verticalAlignment = Alignment.CenterVertically) { NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - VideoUserOptionAction(note, accountViewModel) + VideoUserOptionAction(note, accountViewModel, nav) } Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status( - remember { note.author!! }, - remember { Modifier.weight(1f) }, + note.author!!, + Modifier.weight(1f), accountViewModel, nav = nav, ) @@ -387,6 +389,7 @@ private fun RenderAuthorInformation( private fun VideoUserOptionAction( note: Note, accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { val popupExpanded = remember { mutableStateOf(false) } val enablePopup = remember { { popupExpanded.value = true } } @@ -397,7 +400,7 @@ private fun VideoUserOptionAction( ) { Icon( imageVector = Icons.Default.MoreVert, - null, + contentDescription = stringResource(id = R.string.more_options), modifier = remember { Modifier.size(20.dp) }, tint = MaterialTheme.colorScheme.placeholderText, ) @@ -405,7 +408,9 @@ private fun VideoUserOptionAction( NoteDropDownMenu( note, popupExpanded, + null, accountViewModel, + nav, ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 0d10f938d..585516526 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -56,7 +56,7 @@ val EditFieldBorder = RoundedCornerShape(25.dp) val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) -val StdButtonSizeModifier = Modifier.size(20.dp) +val StdButtonSizeModifier = Modifier.size(19.dp) val HalfVertSpacer = Modifier.height(2.dp) @@ -146,9 +146,9 @@ val Height4dpModifier = Modifier.height(4.dp) val AccountPictureModifier = Modifier.size(55.dp).clip(shape = CircleShape) val HeaderPictureModifier = Modifier.size(34.dp).clip(shape = CircleShape) -val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp) -val ShowMoreRelaysButtonIconModifier = Modifier.size(15.dp) -val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp) +val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(15.dp) +val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp) +val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(17.dp) val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f) @@ -193,6 +193,10 @@ val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizont val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth() +val imageHeaderBannerSize = Modifier.fillMaxWidth().height(150.dp) + +val authorNotePictureForImageHeader = Modifier.size(75.dp).padding(10.dp) + val normalNoteModifier = Modifier.fillMaxWidth() .padding( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 2fcdc92b9..4bfebcc01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -167,14 +167,14 @@ val LightReplyBorderModifier = val DarkInnerPostBorderModifier = Modifier - .padding(top = 5.dp) + .padding(vertical = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, DarkSubtleBorder, QuoteBorder) val LightInnerPostBorderModifier = Modifier - .padding(top = 5.dp) + .padding(vertical = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6242ab259..0c2a3d4a0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -41,6 +41,8 @@ مشاهدة العد تعزيز معزز + تم تعديلها + الأصلية إقتباس مبلغ جديد في Sats إضافة @@ -143,6 +145,7 @@ "اقبل شروط الاستخدام " شروط الاستخدام يجب قبول شروط الإستخدام + كلمة السر مطلوبة المفتاح مطلوب يجب ادخال اسم تسجيل دخول @@ -193,8 +196,19 @@ ضع علامة على جميع الرسائل/الاشعارات الجديدة كمقروء ضع علامة على جميع الرسائل/الاشعارات كمقروء قم بعمل نسخة احتياطية للمفاتيح + ## النصائح الرئيسية للنسخ الاحتياطي والسلامة + \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. + \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. + \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. + \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. + + للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. + \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص. + + فشل في تشفير مفتاحك الخاص مفتاحك السري (nsec) قد تم نسخه في الحافظة إنسخ مفتاحي السري + تشفير و نسخ المفتاح الخاص فشلت المصادقة خطأ "تم إنشاؤه بواسطة %1$s" diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5cd064b5e..1fbfee90c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -6,6 +6,7 @@ Profilový obrázek Skenovat QR kód Zobrazit přesto + Tento příspěvek byl skryt, protože zmiňuje vaše skryté uživatele nebo slova Příspěvek byl označen jako nevhodný uživatelem Příspěvek nenalezen Obrázek kanálu @@ -16,6 +17,7 @@ Spam Zneužívání identity Nezákonné jednání + Jiný Neznámý Ikona předávání Neznámý autor @@ -23,6 +25,9 @@ Kopírovat ID autora Kopírovat ID poznámky Vysílání + Časové razítko + Časové razítko: Čeká na potvrzení + OTS: Čeká Požadovat smazání Blockovat / Nahlásit @@ -43,8 +48,12 @@ Počet zobrazení Zvýšení zvýšeno + upraveno + úprava #%1$s + originál Citovat Rozštěpení + Navrhnout úpravu Nová částka v sats Přidat "odpovídá na " @@ -661,4 +670,21 @@ Git repositář: %1$s Internet: Klon: + OTS: %1$s + Důkaz časového razítka + Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. + Upravit příspěvek + Návrh na vylepšení vašeho příspěvku + Souhrn změn + Rychlé opravy… + Přijmout návrhy + Stáhnout + Text písně zapnuto + Text písně vypnuto + Zapečetěná zpráva vypnuta. Klikněte pro zapnutí zapečetěné zprávy + Zapečetěná zpráva zapnuta. Klikněte pro vypnutí zapečetěné zprávy + Odeslat + Přehrát uživatelské jméno jako audio + Skenovat QR kód + Přejít na poskytovatele peněženky třetí strany Alby diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3f5c2d99d..cd528fecc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -6,6 +6,7 @@ Dein Profilbild QR Code scannen Trotzdem anzeigen + Dieser Beitrag wurde ausgeblendet, weil er Ihre ausgeblendeten Benutzer oder Wörter erwähnt Der Beitrag wurde als unangemessen gekennzeichnet von Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden Kanalbild @@ -16,6 +17,7 @@ Spam Vortäuschung Illegales Verhalten + Andere Unbekannt Relay-Symbol Unbekannter Autor @@ -23,6 +25,9 @@ Autor-ID kopieren Notiz-ID kopieren Übertragungen + Zeitstempel es + Zeitstempel: Ausstehende Bestätigungen + OTS: Ausstehend Löschung beantragen Blockieren / Melden @@ -43,8 +48,12 @@ Aufrufe Boost boosted + bearbeitet + Bearbeitung #%1$s + Original Zitat Fork + Eine Bearbeitung vorschlagen Neuer Betrag in Sats Hinzufügen "Antworten auf " @@ -666,4 +675,21 @@ anz der Bedingungen ist erforderlich Git Repository: %1$s Internet: Klonen: + OTS: %1$s + Zeitstempel Beweis + Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. + Beitrag bearbeiten + Vorschlag zur Verbesserung Ihres Beitrags + Zusammenfassung der Änderungen + Schnelle Korrekturen… + Den Vorschlag annehmen + Herunterladen + Liedtext an + Liedtext aus + Versiegelte Nachricht aus. Klicken Sie, um die versiegelte Nachricht einzuschalten + Versiegelte Nachricht an. Klicken Sie, um die versiegelte Nachricht auszuschalten + Senden + Benutzernamen als Audio abspielen + QR-Code scannen + Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 62a2a8e82..67effe4b5 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -6,6 +6,7 @@ Tu imagen de perfil Escanear el QR Mostrar de todos modos + Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas Post marcado como inapropiado por El evento se está cargando o no se puede encontrar en la lista de relés Imagen del canal @@ -47,8 +48,12 @@ Total de visualizaciones Impulsar Impulsada + editada + edición #%1$s + original Citar Bifurcar + Proponer una edición Nueva cantidad en sats Añadir "respondiendo a " @@ -670,4 +675,18 @@ OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. + Editar publicación + Propuesta para mejorar una publicación + Resumen de cambios + Correcciones rápidas… + Aceptar la sugerencia + Descargar + Letra activada + Letra desactivada + Mensaje sellado desactivado. Haz clic para activar el mensaje sellado. + Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. + Enviar + Reproducir nombre de usuario como audio + Escanear código QR + Ir al proveedor externo de monederos Alby diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index 2852fb8e8..437043ab6 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -6,6 +6,7 @@ Tu imagen de perfil Escanear código QR Mostrar de todos modos + Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas La publicación fue reportada por El evento se está cargando o no se puede encontrar en la lista de relés Imagen del canal @@ -47,8 +48,12 @@ Total de visualizaciones Impulsar Impulsada + editada + edición #%1$s + original Cita Bifurcar + Proponer una edición Nueva cantidad en sats Agregar "respondiendo a " @@ -670,4 +675,18 @@ OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. + Editar publicación + Propuesta para mejorar una publicación + Resumen de cambios + Correcciones rápidas… + Aceptar la sugerencia + Descargar + Letra activada + Letra desactivada + Mensaje sellado desactivado. Haz clic para activar el mensaje sellado. + Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. + Enviar + Reproducir nombre de usuario como audio + Escanear código QR + Ir al proveedor externo de billeteras Alby diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 09a021900..d7f0391f6 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -6,6 +6,7 @@ Tu imagen de perfil Escanear código QR Mostrar de todos modos + Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas La publicación fue reportada por El evento se está cargando o no se puede encontrar en la lista de relés Imagen del canal @@ -47,8 +48,12 @@ Total vistas Impulsar Impulsada + editada + edición #%1$s + original Cita Bifurcar + Proponer una edición Nueva cantidad en sats Agregar "respondiendo a " @@ -670,4 +675,18 @@ OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. + Editar publicación + Propuesta para mejorar una publicación + Resumen de cambios + Correcciones rápidas… + Aceptar la sugerencia + Descargar + Letra activada + Letra desactivada + Mensaje sellado desactivado. Haz clic para activar el mensaje sellado. + Mensaje sellado activado. Haz clic para desactivar el mensaje sellado. + Enviar + Reproducir nombre de usuario como audio + Escanear código QR + Ir al proveedor externo de billeteras Alby diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 266c0422d..5e72a863f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,6 +6,7 @@ Votre Photo de Profil Scanner le QR code Montrer quand même + Ce message a été masqué car il mentionne vos utilisateurs ou mots cachés Post signalé inapproprié par Post non trouvé Image du canal @@ -47,8 +48,12 @@ Nombre de vues Boost boosté + modifié + éditer #%1$s + original Citation Fork + Proposer une Modification Nouveau montant en Sats Ajouter "répondre à " @@ -418,8 +423,8 @@ DÉCONNECTÉ TERMINÉ PLANIFIÉ - Livestream est déconnecté - Livestream terminé + La diffusion en direct est déconnectée + La diffusion en direct est terminée Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ? Tags suivis Relais @@ -670,4 +675,9 @@ OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. + Modifier le Message + Proposition pour améliorer votre publication + Récapitulatif des modifications + Corrections rapides ... + Accepter la Suggestion diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index d4715f21a..ce13985fd 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -6,6 +6,7 @@ Profilkép QR kód beolvasása Mutasd + Ez a bejegyzés el lett rejtve, mert említ rejtett felhasználóidat vagy szavaidat A bejegyzést nem megfelelőként jelölte meg Bejegyzés nem található Csatorna Kép @@ -16,6 +17,7 @@ Spam Megszemélyesítés Illegális viselkedés + Egyéb Ismeretlen Csomópont Ikon Ismeretlen Szerző @@ -23,6 +25,9 @@ A felhasználó PubKulcs másolása A bejegyzés azonosítójának másolása Közvetít + Időbélyegezni + Időbélyeg: függőben lévő megerősítések + OTS: Függőben Törlés Kérése Tiltás és Jelentés @@ -43,7 +48,12 @@ Megtekintések száma Megosztás Megosztva + szerkesztve + szerkesztés #%1$s + eredeti Idézet + Fork + Szerkesztési javaslat Új összeg sats-ban Hozzáad "válasz erre " @@ -647,4 +657,35 @@ Az npub megjelenítése QR-kódként Érvénytelen cím Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s + Zap a fejlesztőknek! + Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít! + Adományozz Most + hozta neked: + Ezt a verziót hozta neked: + %1$s verzió + Köszönöm! + Max Limit + Korlátozott írások + Fork-olva innen + FORK + Git-tár: %1$s + Web: + Klónoz: + OTS: %1$s + Időbélyeg igazolás + Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban a Bitcoin blokkláncába bélyegezték. + Bejegyzés szerkesztése + Javaslat egy bejegyzés javítására + Változások összefoglalása + Gyors javítások… + Fogadd el a Javaslatot + Letöltés + Feliratozás bekapcsolva + Feliratozás kikapcsolva + Lezárt üzenetek kikapcsolva. Kattints a lezárt üzenetek bekapcsolásához + Lezárt üzenetek bekapcsolva. Kattints a lezárt üzenetek kikapcsolásához + Küldés + Felhasználónév lejátszása hangként + QR-kód beolvasása + Keresd fel a harmadik féltől származó pénztárcaszolgáltatót, az Alby-t diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ea7fb69b2..445f7b117 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -16,6 +16,7 @@ Spam Imitatie Illegaal gedrag + Overige Onbekend Relay icoon Onbekende auteur @@ -23,6 +24,9 @@ Kopieer auteur ID Kopieer note ID Verzenden + Tijdstempel + Tijdstempel: in behandeling zijnde bevestigingen + OTS: In behandeling Verwijdering aanvragen Blokkeren / Rapporteren @@ -43,7 +47,10 @@ Aantal keer bekeken Boost boosted + gewijzigd + origineel Citaat + Fork Nieuw bedrag in sats Toevoegen "reageert op " @@ -139,12 +146,15 @@ Wissen App logo nsec.. of npub.. + wachtwoord om de sleutel te openen Toon wachtwoord Verberg wachtwoord Ongeldige sleutel + Ongeldige sleutel: %1$s "Ik accepteer de " gebruiksvoorwaarden Accepteren van de voorwaarden is vereist + Wachtwoord is vereist Sleutel is vereist Een naam is verplicht Inloggen @@ -195,8 +205,19 @@ Nieuwe markeren als gelezen Alles markeren als gelezen Back-up sleutels + ## Sleutel back-up en veiligheidstips + \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. + \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. + \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. + \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. + + Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. + \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen. + + Versleutelen van uw privésleutel is mislukt Privésleutel (nsec) gekopieerd naar klembord Kopieer mijn privésleutel + Versleutel en kopieer mijn geheime sleutel Authenticatie mislukt Biometrie kon de eigenaar van deze telefoon niet verifiëren Biometrie kon de eigenaar van deze telefoon niet verifiëren. Fout: %1$s @@ -634,4 +655,21 @@ Toon npub als een QR-code Ongeldig adres Amethist ontving een URI om te openen, maar die uri was ongeldig: %1$s + Zap de devs! + Jouw donatie helpt ons om een verschil te maken. Elke sat telt! + Nu doneren + werd je aangeboden door: + Deze versie werd je aangeboden door: + Versie %1$s + Bedankt! + Max. limiet + Beperkte schrijfrechten + FORK + Git Repository: %1$s + Web: + Kopiëren: + OTS: %1$s + Bewijs van tijdstempel + Er is bewijs dat dit bericht enige tijd voor %1$sis getekend. Het bewijs is op dat tijdstip en op dat moment getypeerd in de Bitcoin-blockchain. + Bericht wijzigen diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d62aaa9e2..d7484c17d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -6,6 +6,7 @@ Sua Foto de Perfil Ler QR Mostrar de qualquer maneira + Esta postagem foi ocultada porque menciona seus usuários ou palavras ocultas A postagem foi sinalizada como imprópria por postagem não encontrada Imagem do Canal @@ -16,6 +17,7 @@ Spam Representação Comportamento ilegal + Outro Desconhecido Ícone do relay Autor desconhecido @@ -23,6 +25,9 @@ Copiar PubKey do usuário Copiar ID da Nota Transmitir + Carimbá-lo com data/hora + Carimbo de data/hora: Confirmações pendentes + OTS: Pendente Pedir para excluir Bloquear / Denunciar @@ -43,8 +48,12 @@ Contagem de visualizações Impulsionar impulsionado + editado + edição #%1$s + original Citar Garfo + Propor uma Edição Novo Valor em Sats Adicionar "respondendo para " @@ -661,4 +670,21 @@ Repositório Git: %1$s Site: Clonar: + OTS: %1$s + Prova de Carimbo de data/hora + Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. + Editar postagem + Proposta para melhorar sua postagem + Resumo das alterações + Correções rápidas… + Aceitar a Sugestão + Baixar + Letras ligadas + Letras desligadas + Mensagem selada desligada. Clique para ligar a mensagem selada + Mensagem selada ligada. Clique para desligar a mensagem selada + Enviar + Reproduzir nome de usuário como áudio + Escanear código QR + Navegar para o provedor de carteira de terceiros Alby diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index e099d4ffb..0378413f2 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -6,6 +6,7 @@ Din profilbild Scanna QR Visa ändå + Detta inlägg doldes eftersom det nämner dina dolda användare eller ord Inlägget flaggades som olämpligt av Inlägg hittades inte Kanal bild @@ -16,6 +17,7 @@ Spam Imitation Olagligt beteende + Annat Okänd Relä ikon Okänd användare @@ -23,6 +25,9 @@ Kopiera användarens ID Kopiera anteckningens ID Sänd ut + Tidsstämpla det + Tidsstämpel: Väntar på bekräftelser + OTS: Väntar Begär radering Blockera / Rapportera @@ -43,8 +48,12 @@ Antal visningar Boosta boostad + redigerat + redigering #%1$s + original Citera Förgrening + Föreslå en redigering Nytt belopp i Sats Lägg till "Svarar till " @@ -660,4 +669,21 @@ Git Repository: %1$s Webbplats: Klona: + OTS: %1$s + Tidsstämpel Bevis + Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. + Redigera inlägg + Förslag till att förbättra ditt inlägg + Sammanfattning av ändringar + Snabba fixar… + Acceptera förslaget + Ladda ner + Undertexter på + Undertexter av + Förseglat meddelande av. Klicka för att slå på förseglat meddelande + Förseglat meddelande på. Klicka för att stänga av förseglat meddelande + Skicka + Spela upp användarnamn som ljud + Skanna QR-kod + Navigera till tredjeparts plånboksleverantören Alby diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 55de47ddb..16eb4016f 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -3,8 +3,10 @@ สแกน Qr Code โชว์ QR รูปโปรไฟล์ + รูปโปรไฟล์ แสกน QR แสดง + โน๊ตนี้ถูกซ่อนเนื่องจากมีเนื้อหา หรือผู้ใช้ ที่ท่านไม่อยากเห็น โพสต์ถูกรายงานว่าไม่เหมาะสมโดย ไม่พบโพสต์นี้ รูปของ channel @@ -15,6 +17,7 @@ สแปม เลียนแบบ พฤติกรรมที่ผิดกฎหมาย + อื่นๆ ไม่ทราบ รีเลย์ไอคอน ไม่ทราบผู้เขียน @@ -22,6 +25,8 @@ คัดลอก ID ผู้เขียน คัดลอก ID โน้ต เพยแพร่ + ประทับเวลา + กำลังรอยืนยันการประทับเวลา ส่งคำขอให้ลบ บล๊อก / รายงาน diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f015ba35f..c1aa102b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Your Profile Picture Scan QR Show Anyway + This post was hidden because it mentions your hidden users or words Post was muted or reported by Event is loading or can\'t be found in your relay list Channel Image @@ -50,8 +51,12 @@ View count Boost boosted + edited + edit #%1$s + original Quote Fork + Propose an Edit New Amount in Sats Add "replying to " @@ -792,4 +797,21 @@ Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. + + Edit Post + Proposal to improve a post + Summary of changes + Quick fixes… + + Accept the Suggestion + + Download + Lyrics on + Lyrics off + Sealed message off. Click to turn on sealed message + Sealed message on. Click to turn off sealed message + Send + Play username as audio + Scan QR code + Navigate to the third-party wallet provider Alby diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 41f4eb250..cff6738d5 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -60,6 +60,7 @@ import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.coroutines.Dispatchers @@ -73,6 +74,7 @@ fun TranslatableRichTextViewer( modifier: Modifier = Modifier, tags: ImmutableListOfLists, backgroundColor: MutableState, + id: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -97,6 +99,7 @@ fun TranslatableRichTextViewer( modifier, tags, backgroundColor, + id, accountViewModel, nav, ) @@ -111,6 +114,7 @@ private fun RenderText( modifier: Modifier, tags: ImmutableListOfLists, backgroundColor: MutableState, + id: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -129,6 +133,7 @@ private fun RenderText( modifier, tags, backgroundColor, + id, accountViewModel, nav, ) @@ -243,7 +248,7 @@ private fun TranslationMessage( } }, ) - Divider() + HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { if (accountViewModel.account.preferenceBetween(source, target) == source) { @@ -300,7 +305,7 @@ private fun TranslationMessage( } }, ) - Divider() + HorizontalDivider(thickness = DividerThickness) val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) for (i in 0 until languageList.size()) { diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 61ab23cfb..be097e2d7 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'com.android.library' - id 'androidx.benchmark' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.androidBenchmark) } android { @@ -49,10 +49,10 @@ android { } dependencies { - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.3' + androidTestImplementation libs.androidx.runner + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.junit + androidTestImplementation libs.androidx.benchmark.junit4 androidTestImplementation project(path: ':quartz') androidTestImplementation project(path: ':commons') diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt index c3408f582..5bf80dafb 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -49,6 +49,7 @@ class RichTextParserBenchmark { RichTextParser().parseMediaUrl( "https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk", EmptyTagList, + null, ), ) } diff --git a/build.gradle b/build.gradle index e72a837d7..121417ec7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,32 +1,13 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -buildscript { - ext { - fragment_version = "1.6.2" - lifecycle_version = '2.7.0' - compose_ui_version = '1.6.1' - nav_version = '2.7.7' - room_version = "2.4.3" - accompanist_version = '0.34.0' - coil_version = '2.6.0' - vico_version = '1.14.0' - media3_version = '1.2.1' - core_ktx_version = '1.12.0' - material3_version = '1.2.0' - } - dependencies { - classpath 'com.google.gms:google-services:4.4.1' - } -} - plugins { - id 'com.android.application' version '8.2.2' apply false - id 'com.android.library' version '8.2.2' apply false - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false - id 'org.jetbrains.kotlin.jvm' version '1.9.22' apply false - id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false - id 'androidx.benchmark' version '1.2.3' apply false - id 'com.diffplug.spotless' version '6.25.0' apply false + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.jetbrainsKotlinAndroid) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.jetbrainsKotlinJvm) apply false + alias(libs.plugins.androidBenchmark) apply false + alias(libs.plugins.diffplugSpotless) apply false + alias(libs.plugins.googleServices) apply false } subprojects { diff --git a/commons/build.gradle b/commons/build.gradle index a30b159dd..6adfe0abd 100644 --- a/commons/build.gradle +++ b/commons/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsKotlinAndroid) } android { @@ -38,12 +38,13 @@ dependencies { implementation project(path: ':quartz') // Import @Immutable and @Stable - implementation "androidx.compose.ui:ui:$compose_ui_version" + implementation platform(libs.androidx.compose.bom) + implementation libs.androidx.ui // immutable collections to avoid recomposition - api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7') + api libs.kotlinx.collections.immutable - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation libs.junit + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core } \ No newline at end of file diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt index bb205711b..dcd6b6eb4 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt @@ -45,6 +45,7 @@ class RichTextParser() { fun parseMediaUrl( fullUrl: String, eventTags: ImmutableListOfLists, + description: String?, ): MediaUrlContent? { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { @@ -53,7 +54,7 @@ class RichTextParser() { MediaUrlImage( url = fullUrl, - description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], @@ -64,7 +65,7 @@ class RichTextParser() { val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists) MediaUrlVideo( url = fullUrl, - description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], @@ -106,7 +107,7 @@ class RichTextParser() { val urlSet = parseValidUrls(content) val imagesForPager = - urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content) }.associateBy { it.url } val imageList = imagesForPager.values.toList() val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..6ac65c0e2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,124 @@ +[versions] +accompanistAdaptive = "0.34.0" +activityCompose = "1.8.2" +agp = "8.3.0" +androidKotlinGeohash = "1.0" +androidLifecycle = "2.7.0" +androidxJunit = "1.2.0-alpha03" +appcompat = "1.6.1" +audiowaveform = "1.1.1" +benchmark = "1.2.3" +benchmarkJunit4 = "1.2.3" +biometricKtx = "1.2.0-alpha05" +blurhash = "1.0.0" +coil = "2.6.0" +composeBom = "2024.02.01" +coreKtx = "1.12.0" +espressoCore = "3.5.1" +firebaseBom = "32.7.3" +fragmentKtx = "1.6.2" +gms = "4.4.1" +jacksonModuleKotlin = "2.16.1" +jna = "5.14.0" +jsoup = "1.17.2" +junit = "4.13.2" +kotlin = "1.9.22" +kotlinxCollectionsImmutable = "0.3.7" +languageId = "17.0.5" +lazysodiumAndroid = "5.1.0" +lightcompressor = "1.3.2" +markdown = "48702a8ced" +media3 = "1.2.1" +mockk = "1.13.10" +navigationCompose = "2.7.7" +okhttp = "5.0.0-alpha.12" +runner = "1.5.2" +secp256k1KmpJniAndroid = "0.15.0" +securityCryptoKtx = "1.1.0-alpha06" +spotless = "6.25.0" +translate = "17.0.2" +unifiedpush = "2.2.0" +urlDetector = "0.1.23" +vico-charts = "1.14.0" +zelory = "3.0.1" +zoomable = "1.6.0" +zxing = "3.5.3" +zxingAndroidEmbedded = "4.3.0" + +[libraries] +abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } +accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmarkJunit4" } +androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidLifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidLifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidLifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidLifecycle" } +androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" } +androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-runtime-runtime = { group = "androidx.compose.runtime", name = "runtime" } +androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +audiowaveform = { group = "com.github.lincollincol", name = "compose-audiowaveform", version.ref = "audiowaveform" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" } +coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } +drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } +google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } +jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } +markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } +markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } +markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } +trbl-blurhash = { group = "io.trbl", name = "blurhash", version.ref = "blurhash" } +unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } +url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" } +vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" } +vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" } +vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" } +vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } +zelory-video-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } +zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } +zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidBenchmark = { id = "androidx.benchmark", version.ref = "benchmark" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +diffplugSpotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +googleServices = { id = "com.google.gms.google-services", version.ref = "gms" } +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df554462d..c738a045f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/quartz/build.gradle b/quartz/build.gradle index c5ff3ec13..a9aa482f5 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -1,7 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsKotlinAndroid) } android { @@ -34,36 +33,39 @@ android { jvmTarget = '17' freeCompilerArgs += "-Xstring-concat=inline" } - packagingOptions { - // delete native scrypt lib because we cannot use dylibs in android - exclude '**/libscrypt.dylib' + resources { + excludes += ['**/libscrypt.dylib'] + } } } dependencies { - implementation "androidx.core:core-ktx:$core_ktx_version" + implementation libs.androidx.core.ktx + + implementation platform(libs.androidx.compose.bom) // @Immutable and @Stable - implementation "androidx.compose.runtime:runtime:$compose_ui_version" + implementation libs.androidx.runtime.runtime // Bitcoin secp256k1 bindings to Android - api 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.14.0' + api libs.secp256k1.kmp.jni.android // LibSodium for ChaCha encryption (NIP-44) + // Wait for @aar support in version catalogs implementation "com.goterl:lazysodium-android:5.1.0@aar" implementation 'net.java.dev.jna:jna:5.14.0@aar' // Performant Parser of JSONs into Events - api 'com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1' + api libs.jackson.module.kotlin // immutable collections to avoid recomposition - api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7') + api libs.kotlinx.collections.immutable // Parses URLs from Text: - api "io.github.url-detector:url-detector:0.1.23" + api libs.url.detector - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation libs.junit + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core } \ No newline at end of file diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt index 326fe5bb1..8876fd703 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP19EmbedTests.kt @@ -24,6 +24,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -134,19 +136,18 @@ class NIP19EmbedTests { assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) } - /* @Test - fun testCompressionSizes() { + fun testVisionPrescriptionBundle2EmbedEvent() { val signer = NostrSignerInternal( - KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), ) var eyeglassesPrescriptionEvent: Event? = null val countDownLatch = CountDownLatch(1) - FhirResourceEvent.create(fhirPayload = visionPrescriptionFhir, signer = signer) { + FhirResourceEvent.create(fhirPayload = visionPrescriptionBundle2, signer = signer) { eyeglassesPrescriptionEvent = it countDownLatch.countDown() } @@ -155,21 +156,20 @@ class NIP19EmbedTests { assertNotNull(eyeglassesPrescriptionEvent) - val json = eyeglassesPrescriptionEvent!!.toJson() + val bech32 = Nip19Bech32.createNEmbed(eyeglassesPrescriptionEvent!!) - Bech32.encodeBytes(hrp = "nembed", json.toByteArray(), Bech32.Encoding.Bech32) - Bech32.encodeBytes(hrp = "nembed", gzip(json), Bech32.Encoding.Bech32) + println(eyeglassesPrescriptionEvent!!.toJson()) + println(bech32) - val (bech32json, elapsedjson) = measureTimedValue { json.toByteArray().toNEmbed() } - val (bech32gzip, elapsedgzip) = measureTimedValue { gzip(json).toNEmbed() } + val decodedNote = (Nip19Bech32.uriToRoute(bech32)?.entity as Nip19Bech32.NEmbed).event - println("Raw JSON ${json.length} chars") - println("Bech32 JSON ${bech32json.length} chars in $elapsedjson") - println("Bech32 GZIP ${bech32gzip.length} chars in $elapsedgzip") + assertTrue(decodedNote.hasValidSignature()) - assertTrue(true) - }*/ + assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + } val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + + val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt index d4d2bb178..775a7c85f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt @@ -30,7 +30,7 @@ class Lud06 { fun toLud16(str: String): String? { return try { - val url = toLnUrlp(str) + val url = toLnUrlp(str) ?: return null val matcher = LNURLP_PATTERN.matcher(url) if (matcher.find()) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt index 58f43b9dc..c2258dad6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt @@ -86,7 +86,7 @@ class RelayInformationLimitation( ) class RelayInformationRetentionData( - val kinds: ArrayList, + val kinds: ArrayList?, val tiem: Int?, val count: Int?, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt index 0516f1916..31267b345 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -94,7 +94,9 @@ object Nip19Bech32 { val key = matcher.group(3) // bech32 val additionalChars = matcher.group(4) // additional chars - return parseComponents(type!!, key, additionalChars) + if (type == null) return null + + return parseComponents(type, key, additionalChars) } catch (e: Throwable) { Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) } @@ -111,6 +113,7 @@ object Nip19Bech32 { val bytes = (type + key).bechToBytes() when (type.lowercase()) { + "nsec1" -> nsec(bytes) "npub1" -> npub(bytes) "note1" -> note(bytes) "nprofile1" -> nprofile(bytes) @@ -133,6 +136,11 @@ object Nip19Bech32 { return NEmbed(Event.fromJson(ungzip(bytes))) } + private fun nsec(bytes: ByteArray): NSec? { + if (bytes.isEmpty()) return null + return NSec(bytes.toHexKey()) + } + private fun npub(bytes: ByteArray): NPub? { if (bytes.isEmpty()) return null return NPub(bytes.toHexKey()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index 26a522e49..aa65eb642 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -55,6 +55,8 @@ open class BaseTextNoteEvent( fun forkFromVersion() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "fork" }?.get(1) + fun isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) } + open fun replyTos(): List { val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) @@ -105,14 +107,16 @@ open class BaseTextNoteEvent( val additionalChars = matcher2.group(4) // additional chars try { - val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity + if (type != null) { + val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity - if (parsed != null) { - if (parsed is Nip19Bech32.NProfile) { - returningList.add(parsed.hex) - } - if (parsed is Nip19Bech32.NPub) { - returningList.add(parsed.hex) + if (parsed != null) { + if (parsed is Nip19Bech32.NProfile) { + returningList.add(parsed.hex) + } + if (parsed is Nip19Bech32.NPub) { + returningList.add(parsed.hex) + } } } } catch (e: Exception) { @@ -151,14 +155,16 @@ open class BaseTextNoteEvent( val key = matcher2.group(3) // bech32 val additionalChars = matcher2.group(4) // additional chars - val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity + if (type != null) { + val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity - if (parsed != null) { - when (parsed) { - is Nip19Bech32.NEvent -> citations.add(parsed.hex) - is Nip19Bech32.NAddress -> citations.add(parsed.atag) - is Nip19Bech32.Note -> citations.add(parsed.hex) - is Nip19Bech32.NEmbed -> citations.add(parsed.event.id) + if (parsed != null) { + when (parsed) { + is Nip19Bech32.NEvent -> citations.add(parsed.hex) + is Nip19Bech32.NAddress -> citations.add(parsed.atag) + is Nip19Bech32.Note -> citations.add(parsed.hex) + is Nip19Bech32.NEmbed -> citations.add(parsed.event.id) + } } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index e57df43d8..b46607c12 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -429,25 +429,50 @@ class UserMetadata { } fun lnAddress(): String? { - return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null } + return lud16 ?: lud06 } fun bestUsername(): String? { - return name?.ifBlank { null } ?: username?.ifBlank { null } + return name ?: username } fun bestDisplayName(): String? { - return displayName?.ifBlank { null } + return displayName } fun nip05(): String? { - return nip05?.ifBlank { null } + return nip05 } fun profilePicture(): String? { - if (picture.isNullOrBlank()) picture = null return picture } + + fun cleanBlankNames() { + if (picture?.isNotEmpty() == true) picture = picture?.trim() + if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim() + if (displayName?.isNotEmpty() == true) displayName = displayName?.trim() + if (name?.isNotEmpty() == true) name = name?.trim() + if (username?.isNotEmpty() == true) username = username?.trim() + if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() + if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + + if (banner?.isNotEmpty() == true) banner = banner?.trim() + if (website?.isNotEmpty() == true) website = website?.trim() + if (domain?.isNotEmpty() == true) domain = domain?.trim() + + if (picture?.isBlank() == true) picture = null + if (nip05?.isBlank() == true) nip05 = null + if (displayName?.isBlank() == true) displayName = null + if (name?.isBlank() == true) name = null + if (username?.isBlank() == true) username = null + if (lud06?.isBlank() == true) lud06 = null + if (lud16?.isBlank() == true) lud16 = null + + if (banner?.isBlank() == true) banner = null + if (website?.isBlank() == true) website = null + if (domain?.isBlank() == true) domain = null + } } @Stable class ImmutableListOfLists(val lists: Array>) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index aba51ab92..1d8549679 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -386,7 +386,7 @@ open class Event( put("pubkey", pubKey) put("created_at", createdAt) put("kind", kind) - put( + replace( "tags", factory.arrayNode(tags.size).apply { tags.forEach { tag -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 951901e4e..306277d04 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -126,6 +126,7 @@ class EventFactory { SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index 55f0b4b2b..ae2d56396 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -63,7 +63,7 @@ interface EventInterface { fun isTaggedUser(idHex: String): Boolean - fun isTaggedUsers(idHex: Set): Boolean + fun isTaggedUsers(idHexes: Set): Boolean fun isTaggedEvent(idHex: String): Boolean diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt index ed11d2015..2c1752ffe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt @@ -41,7 +41,7 @@ class GitIssueEvent( private fun repositoryHex() = innerRepository()?.getOrNull(1) - fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + fun rootIssueOrPatch() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) fun repository() = innerRepository()?.let { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt index d9a6670e1..4c5a04ce8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt @@ -60,7 +60,7 @@ class GitPatchEvent( fun commitPGPSig() = tags.firstOrNull { it.size > 1 && it[0] == "commit-pgp-sig" }?.get(1) fun committer() = - tags.filter { it.size > 1 && it[0] == "committer" }?.mapNotNull { + tags.filter { it.size > 1 && it[0] == "committer" }.mapNotNull { Committer(it.getOrNull(1), it.getOrNull(2), it.getOrNull(3), it.getOrNull(4)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt index a6b1a5658..f083c895f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -57,7 +58,7 @@ class GitReplyEvent( companion object { const val KIND = 1622 - const val ALT = "A Git Issue" + const val ALT = "A Git Reply" fun create( patch: String, @@ -75,5 +76,87 @@ class GitReplyEvent( signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + + fun create( + msg: String, + replyTos: List? = null, + mentions: List? = null, + addresses: List? = null, + extraTags: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + replyingTo: String? = null, + root: String? = null, + directMentions: Set = emptySet(), + geohash: String? = null, + nip94attachments: List? = null, + forkedFrom: Event? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GitReplyEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "e", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = forkedFrom?.id, + ), + ) + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("q", it)) + } + } + addresses + ?.map { it.toTag() } + ?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "a", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), + ), + ) + } + findHashtags(msg).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + extraTags?.forEach { tags.add(arrayOf("t", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } + tags.add(arrayOf("alt", "a git issue reply")) + + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 79a3161b2..13f81f043 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -123,45 +123,45 @@ class TextNoteEvent( signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } - - /** - * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the - * deprecated method of positional tags to maximize backwards compatibility with clients that - * support replies but have not been updated to understand tag markers. - * - * https://github.com/nostr-protocol/nips/blob/master/10.md - * - * The tag to the root of the reply chain goes first. The tag to the reply event being responded - * to goes last. The order for any other tag does not matter, so keep the relative order. - */ - private fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - directMentions: Set, - forkedFrom: String?, - ) = sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } - } - .map { - when (it) { - root -> arrayOf(tagName, it, "", "root") - replyingTo -> arrayOf(tagName, it, "", "reply") - forkedFrom -> arrayOf(tagName, it, "", "fork") - in directMentions -> arrayOf(tagName, it, "", "mention") - else -> arrayOf(tagName, it) - } - } } } fun findURLs(text: String): List { return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } } + +/** + * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the + * deprecated method of positional tags to maximize backwards compatibility with clients that + * support replies but have not been updated to understand tag markers. + * + * https://github.com/nostr-protocol/nips/blob/master/10.md + * + * The tag to the root of the reply chain goes first. The tag to the reply event being responded + * to goes last. The order for any other tag does not matter, so keep the relative order. + */ +fun List.positionalMarkedTags( + tagName: String, + root: String?, + replyingTo: String?, + directMentions: Set, + forkedFrom: String?, +) = sortedWith { o1, o2 -> + when { + o1 == o2 -> 0 + o1 == root -> -1 // root goes first + o2 == root -> 1 // root goes first + o1 == replyingTo -> 1 // reply event being responded to goes last + o2 == replyingTo -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } +} + .map { + when (it) { + root -> arrayOf(tagName, it, "", "root") + replyingTo -> arrayOf(tagName, it, "", "reply") + forkedFrom -> arrayOf(tagName, it, "", "fork") + in directMentions -> arrayOf(tagName, it, "", "mention") + else -> arrayOf(tagName, it) + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt new file mode 100644 index 000000000..0094c5983 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TextNoteModificationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun editedNote() = firstTaggedEvent() + + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + + companion object { + const val KIND = 1010 + const val ALT = "Content Change Event" + + fun create( + content: String, + eventId: HexKey, + notify: HexKey?, + summary: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TextNoteModificationEvent) -> Unit, + ) { + val tags = mutableListOf(arrayOf("e", eventId)) + + notify?.let { + tags.add(arrayOf("p", it)) + } + + summary?.let { + tags.add(arrayOf("summary", it)) + } + + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt index bff1e2eb8..0d9c1f252 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt @@ -33,8 +33,8 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.LnZapRequestEvent @@ -90,9 +90,7 @@ class Result( * Parses the json with a string of events to an Array of Event objects. */ fun fromJsonArray(json: String): Array { - return Event.mapper.readTree(json).map { - fromJson(it.asText()) - }.toTypedArray() + return mapper.readValue(json) } } } diff --git a/settings.gradle b/settings.gradle index 6ac181c95..45a4461c7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,8 +1,14 @@ pluginManagement { repositories { - gradlePluginPortal() - google() + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } mavenCentral() + gradlePluginPortal() maven { url "https://jitpack.io" content { @@ -11,6 +17,7 @@ pluginManagement { } } } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -19,6 +26,7 @@ dependencyResolutionManagement { maven { url "https://jitpack.io" } } } + rootProject.name = "Amethyst" include ':app' include ':benchmark'