From 84a031b931b277eac22e2c4659e47924f12653b2 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 19 Aug 2026 10:59:21 +0300 Subject: [PATCH] feat(desktop): enrich the DM chat composer + message UX Brings the desktop DM experience up to par with a modern chat client, reusing existing commons/quartz infrastructure throughout. Composer: - Emoji picker (org.kodein.emoji, MIT) with search; unicode + NIP-30 custom-emoji ":shortcode:" autocomplete in one strip - @mention autocomplete (avatar + display name) inserting nostr:npub - Reply-quoting: composer bar + a quoted chip on reply bubbles that taps to scroll-and-highlight the original (bounce + flash) - Per-message image quality selector; send-in-progress spinner Rendering: - DM bubbles render rich text (mentions resolve to names; image/GIF/ custom-emoji inline) instead of raw text GIF search (Nostr-native, no third-party API): - Query NIP-94 kind-1063 GIF metadata from a configurable relay list (nos.lol + relay.damus.io), dedup + client-side filter, animated preview Uploads / privacy: - Encrypted DM files upload as opaque application/octet-stream by default so the media server can't learn the media type; optional "Reveal media type" toggle for strict servers. Only reencode jpg/png/webp; GIF/video/ AVIF/HEIC pass through. Actionable errors for servers that reject private uploads - Reply/glyph: add a real Reply icon to the Material Symbols subset Robustness: - Preserve Messages state (selected room, draft, attachments) across the device lock by hoisting it above the lock gate - Thread the send so the composer always clears and errors surface Adds unit tests for the GIF merge/filter and caret insertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../font/material_symbols_outlined.ttf | Bin 475928 -> 475740 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + .../commons/viewmodels/ChatNewMessageState.kt | 12 + .../service/upload/UploadOrchestrator.kt | 115 +++- desktopApp/build.gradle.kts | 3 + ...chat-composer-enrichment-manual-testing.md | 60 ++ .../amethyst/desktop/DesktopPreferences.kt | 25 + .../amethyst/desktop/ImageCompressionStore.kt | 11 + .../amethyst/desktop/ui/chats/ChatPane.kt | 626 ++++++++++++++++-- .../desktop/ui/chats/DesktopMessagesScreen.kt | 58 +- .../ui/chats/composer/EmojiPickerPanel.kt | 129 ++++ .../ui/chats/composer/EmojiSuggestion.kt | 120 ++++ .../ui/chats/composer/GifPickerPanel.kt | 147 ++++ .../desktop/ui/chats/composer/GifResult.kt | 94 +++ .../ui/chats/composer/MentionSuggestions.kt | 101 +++ .../desktop/ui/deck/DeckColumnContainer.kt | 34 + .../ui/settings/ImageCompressionSettings.kt | 36 + .../desktop/ui/chats/InsertAtCursorTest.kt | 50 ++ .../ui/chats/composer/GifResultTest.kt | 75 +++ gradle/libs.versions.toml | 2 + 20 files changed, 1584 insertions(+), 115 deletions(-) create mode 100644 desktopApp/plans/2026-08-18-chat-composer-enrichment-manual-testing.md create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiPickerPanel.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiSuggestion.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/MentionSuggestions.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/InsertAtCursorTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResultTest.kt diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index e75dfdb2c22e137c8c4d9f4ef257b40537b24a3c..3cae70628c53e0dfdb568979b9e3e418ad05c427 100644 GIT binary patch delta 2662 zcmX|D3tUuH8vma;=iXr$X69nU++i4I1~OJ%%@@j3-trQO01*()NmMK_^3kHXjyi6d zuTY98&HPpCnrmfIYxX18ezt06t|=OpvSdb>U?Y!VfC2Z6`?$Z~|9t0s=X;&+`|i27 z;f2uan?uKg00`s*gOpJ)C#Au%_a1Pk10Z`ci<0w6Egk{7)^VwEyT_XxU5EJWOZ#*7 z6$rR}6u4W-*LjPo?9~CA&TvWQ0mSwte1CgS)%$$EiSKtVwXdw^RN|J~IQ3gvxn{|x zH}7l(w0wQ4V=0#pXe(9$za4^@|53$adwF!o)(jqnnafYF;0)zWWf&YFg`6?EqH0x5 z$)D!_g$r+*@+Bmj|BE#rOFs=t-`J_SfR~KcIh=xGYeYMq-GgaXraWkebHR zbSkkbIGb|l`RGfg9!$K%{qEumC3e-Bgvn$zkITfaSF1 zq~(a^bIWIzILkuIywGEzzEZ#-w zpOW{>{XPxjPIx9WwFy^Q)BaD7sonckH-EO_fXu} zxb(RCxHIvZ_}uuF@mps^&Uksoxf$IFDG3z`^$EWuY7?g<&PjYK@p6(W>G`BDle3ez zB_B_Ar5IC|r5s2lsgI^^Pd%RMPMesvEbT={+O?ShGhd&1D}8)=NqTMivGhwBnvA%N zof(%iCuCMZr+Dw)5=~d zyEgy#^HylRjl+cWsQz z6;n%|DOi2>z?AgbXqPLxc6Gsl#-ep!%q3InjLm=M$tC%ukQ9+(QcB8rJ1itml5$^rkf%r`Sx&0Sin@``83c9lUBSxr|7qIB zVwYN>lE`{rGm9;amEHTPW8+=W&9R_c)`Gs+3c6zw=u1z6?x_HMWgqBkpMk!?@#aF% zx0^r@z5{ybD(JgsK@W5Ok$TV%;yF%&9!&%Na17{24WOTlfF3UgZQ?dhFwnn`2R&T_ z`W4r^umkk61bX!Z9P~SGpuHEgs}l4cH`1E~>gBxqqru2|FjY5Lzz#5N1y~^GjWU9T zaBAgy6C|+7g<#VzfyHhHn=u|NxfLvJA=s?7V7c{R#VufE{FAeU%PzBlEq@E_*0_J z13MW3c82r5=8pck0_@@oV3$fc++bHsU{^)37EZ5m=hw5rZgASVmZKf4jR*C!qXF#K za`!TLKPkaP(2A_Vay1WgVEofm>N1;XfR2#+!d(~=-0+=Y0v)dX==1w^?BV(0~k;qee7CP5tA z3vuc~h%ti@6S!>Z5r`SrAHSD|XQ#hj5QG5q4h=z&L{U^rfhu>K zTNNl7Y&M%yY8x5s?d|p6A+o5|YK4(Dr{r?E94?otchET4t8#U9baXf+|3TlQ)4N-4 zba!|65BfVbdc8hC@xxs&ymx<4uopIHy*>*=eheBeI^x~DN#kn&_Kp-O-TAithx6yp zJEchY^KT+Q%yqQ4xA!5^DRJitb!6yB>quy%S|1)B?vz^5@3?*YHril=EXyjiI{j7u zKb}Ix4+O8<3#GrxJu))Fvq|ZQG(0>!$ z>k7g~1y|q51V4X&f2ZWL@jo+^k`cF4@;bcU0k?Zl)Qf{|cTZ<$r&H1src^2wn)}_} z{{H@9qIYU|COr2|8Vag0-5=%|pkAJNHaLua#+ZU`mMEnd?it{h*g%gjJdaaS!50)S pp9efm!%#Ul1R#@sI^iB7t~s-bKslF_nmv*JLleW?z{K@|6HH%zNXyW z8w`LG4IHY-#N^O5V)a#^bv?jVM5P!LmZ>WH01pQOLht3?T1$Hx%|B``HW$M2x9@?+ zI(m=K$}<;qk87b-{3RgWm`wS_PxH1@K9};pXPb)($g8OGO7eEuxw9rWx9S!E3hH)b zTQW=Hp|)#rgCpjs^KeGnX#jYXO>; z0czfL3^pA{UVEKBFisiRVP_*8qII}e+%mq)f4rL30afA5kCj?WnaRd_N*K&MsTqZ7 z<8Y?2^Dv#krJty81-;m!qDm_cvW^rhne}HWlUW=+m7ewAEGlE$+Q_WjbHLopz^l(dFwRb$xW*N!%8)casrb*O9sIRFn$!2+xYzdc7t8d8( z@&G#Zt8#Z)R6kR1qvz?eqxyiXQ0J+eWvGX`%yD__lBBj(4{*Nk{J>>^ONC32^I|8Z z>a0_?(?O@Fs;thoPWFJZMfF|NH24P^bX zhzVGOb-0R4XvAfF03Upa_4t)J;yiBPB3|JNns6FFp#j73ZybRuyy1vv=)eQ?Wm%|0 z6l$>x7v1|;%b2gUcV-Nya9tv3^wm^p#=7T#nEZKV1;R>^~`gr;z`pom$?bGVp z-*D_fI~p)~UEZ#H@xbB)!;i^f->F`;FlEn%v# zys+x9tKm-JG2zp~_gTUpjhH*)c!VM%Afhm0ZA4u}Tcmqre&ntwWmG~`Y1DbsJ0_zk z!&G3JXIg98W;$WI7A;1*Mh}fPMyE#aiIHMv#Wcry#>T~##U71)GIIIITXA`D&7;Cb zm5*wV4~t(He|U7i(UV3W9^IahoUl02A#qG%Yf@O!mE_^cyOSTJ4C$YenQ}R`Z)#EM z#?+3qVQD+l#q_lFbs5TxDH)f?xQ$60QhU#bVeuJIxm+^VbfS1 z%V#sHq8|oX`u-njUC49n8dapcVDq{%nl8^sQUr>MkVru6&<*x*AP9Ab0!q?QC-lHO zBr`5hLxzR`riB|p#vOVX-~mr~;a&7ZFZ>Pfp*Q-VFWx5*_OqZr24El|Z7`v3D4};4 z;nx>_@J9dw5rklb5NeGGMHs>{0uhKLw3-l&7{p>E;xG#F7>xuZA_>VzK`PRajtq=J zCdLvM#$i0nn1C!y#3W2cHgYfpQ!x#>u;gJn@=<^pC`1uv;_oQNKTv{Mn2kA@i+Ly| zh5ra2qYR&5J{F)H3-KvF!y+t31(u)^OR)^gu>w_CiB(vQ&xw$0iIUYs!wsmxKd}*C z;7fdkP57E<_6?EkTcXrfY_s4yY{w3KkAGn&ti;4!*o{4?#}C+xeb|o!_z?$jh+q3Wcy_CWK|PAyjRL@c9`C)npqAA$-vQVbg91 zo9{#T<{E@rif^ff@Le3)MF`tdAnfpm@ckADbt(vZiXrT!n)}rd4g^Cux(LDvT6d-f z!UZn~jprdW)xsj&l_0d1LTIBRPZA)!qN0BdgYfnSxE%+#uK{9^eycZuVgC+&b_A34A75u>ySV zP7AnYJ@}#q@TC*LSM~&7{TzI)KX~;vaQbror6c&J=ZRRJ0HAZ4fy$7@C$a}m%PCnHQ<-YU!msLGQpe4U#}p034Vh%)v^WrzNHwvtscDN z4fxwMi0n2*ei5R>hY%GCi0(-cy~-eZt04|afEWabMlHk`FNpDzA*N+O%s38lVm!p` zYY_7vK`h)3aSp{xDgIdl#L8z7*HuE?NHw>fgt*fN;=%n8kCa3F@Z=_lr=uZW-U6|? z9%4%<#Jl$(KFopmm}0M}@S9plJPDF*10>aKNG>^$+-^ehh=bHK6jE;q(x5^}zK)Or qX|C}{NFyFWGX4HZeaFK34n}6Pr@lit`_aOp5>6)WDJqeEJ@sFYP{b$z diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index d8e8b48b29..21ff17449e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -209,6 +209,7 @@ object MaterialSymbols { val RemoveCircleOutline = MaterialSymbol("\uF08F") val RemoveDone = MaterialSymbol("\uE9D3") val Replay = MaterialSymbol("\uE042") + val Reply = MaterialSymbol("\uE15E") val Report = MaterialSymbol("\uF052") val Restaurant = MaterialSymbol("\uE56C") val Save = MaterialSymbol("\uE161") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index 0a9608313b..477afd899d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -72,6 +74,13 @@ class ChatNewMessageState( * cache-only strict check. */ private val dmInboxResolver: (suspend (HexKey) -> List?)? = null, + /** + * Optional NIP-30 custom-emoji pack state. When provided, [sendNip17] + * resolves any `:shortcode:` in the outgoing message against the account's + * selected packs and attaches the matching `emoji` tags so recipients render + * the custom emojis. Null (default) → no custom-emoji tagging. + */ + private val emojiPacks: EmojiPackState? = null, ) { private val _message = MutableStateFlow(TextFieldValue("")) val message: StateFlow = _message.asStateFlow() @@ -208,6 +217,7 @@ class ChatNewMessageState( val replyHint = _replyTo.value?.toEventHint() val subjectText = _subject.value.text.ifBlank { null } + val emojiTags = emojiPacks?.findEmojiTags(messageText)?.takeIf { it.isNotEmpty() } val template = if (replyHint == null) { @@ -216,6 +226,7 @@ class ChatNewMessageState( hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) + emojiTags?.let { emojis(it) } } } else { ChatMessageEvent.reply(messageText, replyHint) { @@ -223,6 +234,7 @@ class ChatNewMessageState( hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) + emojiTags?.let { emojis(it) } } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 058dc558d7..9f28a12765 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -161,41 +161,94 @@ class UploadOrchestrator( cipher: AESGCM, serverBaseUrl: String, signer: NostrSigner, + // Defaults preserve the prior behaviour exactly (no reencode, no EXIF + // strip → encrypt raw bytes) for existing Android/CLI callers. Only + // callers that opt in (e.g. Desktop DMs) change what leaves the machine. + stripExif: Boolean = false, + quality: CompressionQuality? = null, + // When false (default) the encrypted blob is uploaded as opaque + // application/octet-stream so the server can't learn the media type. + // When true it's uploaded with the real media type — less private, but + // required by strict Blossom servers that reject octet-stream (HTTP 415). + declareRealMimeType: Boolean = false, ): EncryptedUploadResult { - // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) - val metadata = MediaMetadataReader.compute(file) + var reencodedTemp: File? = null + var strippedTemp: File? = null + try { + // 1. Re-encode (or pass-through) BEFORE encrypting, mirroring [upload]. + // quality == null → pass-through (preserves prior DM behaviour, and + // the CLI/Android callers). ImageReencoder itself passes animated + // GIFs through byte-identical, so GIF DMs stay animated. + val reencode = + when { + quality == null -> ReencodeResult.PassThrough(ImageReencoder.PassReason.BypassByUser) + else -> ImageReencoder.reencode(file, quality) + } + val afterReencode = + when (reencode) { + is ReencodeResult.Reencoded -> reencode.file.also { reencodedTemp = it } + is ReencodeResult.PassThrough -> file + } - // 2. Read file bytes and encrypt - val plaintext = file.readBytes() - val encrypted = cipher.encrypt(plaintext) + // 2. Strip EXIF only when shipping the original (re-encode wipes it). + val finalFile = + if (stripExif && reencode is ReencodeResult.PassThrough) { + val stripped = MediaCompressor.stripExif(afterReencode) + if (stripped != afterReencode) strippedTemp = stripped + stripped + } else { + afterReencode + } - // 3. Compute SHA256 of ENCRYPTED blob (not plaintext) - val encryptedHash = sha256(encrypted).toHexKey() - val encryptedSize = encrypted.size + // 3. Compute pre-encryption metadata on the bytes that get encrypted + // (dimensions, blurhash, mime, originalHash of the final plaintext). + val metadata = MediaMetadataReader.compute(finalFile) - // 4. Create Blossom auth with encrypted hash and size - val authHeader = - BlossomAuth.createUploadAuth( - hash = encryptedHash, - size = encryptedSize.toLong(), - alt = "Encrypted upload", - signer = signer, + // 4. Read final bytes and encrypt + val plaintext = finalFile.readBytes() + val encrypted = cipher.encrypt(plaintext) + + // 5. Compute SHA256 of ENCRYPTED blob (not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size + + // 6. Create Blossom auth with encrypted hash and size + val authHeader = + BlossomAuth.createUploadAuth( + hash = encryptedHash, + size = encryptedSize.toLong(), + alt = "Encrypted upload", + signer = signer, + ) + + // 7. Upload the encrypted blob. Default: declare + // application/octet-stream so the server can't learn whether a + // private attachment is an image, video, voice note, etc. — this + // needs a server that accepts opaque blobs (e.g. nostr.download). + // Callers may opt into [declareRealMimeType] to declare the real + // type instead, so strict servers that reject octet-stream (HTTP + // 415) also work, at the cost of leaking the media category. + val uploadContentType = + if (declareRealMimeType) metadata.mimeType else "application/octet-stream" + val result = + client.upload( + bytes = encrypted, + contentType = uploadContentType, + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return EncryptedUploadResult( + blossom = result, + metadata = metadata, + encryptedHash = encryptedHash, + encryptedSize = encryptedSize, ) - - // 5. Upload encrypted blob as opaque binary - val result = - client.upload( - bytes = encrypted, - contentType = "application/octet-stream", - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) - - return EncryptedUploadResult( - blossom = result, - metadata = metadata, - encryptedHash = encryptedHash, - encryptedSize = encryptedSize, - ) + } finally { + withContext(NonCancellable) { + strippedTemp?.deleteOrWarn("UploadOrchestrator", "encrypted stripped temp") + reencodedTemp?.deleteOrWarn("UploadOrchestrator", "encrypted reencoded temp") + } + } } } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 36114b3428..7e3fea51fb 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -57,6 +57,9 @@ dependencies { implementation(libs.coil.okhttp) implementation(libs.coil.svg) + // Unicode emoji picker (Kodein Emoji.kt — MIT; offline system-font rendering on desktop) + implementation(libs.kodein.emoji.compose.m3) + // Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer) // composemediaplayer 0.10.0 leaks kotlinx-coroutines-test as a *runtime* dependency // in its published POM. That jar ships a META-INF/services registration for diff --git a/desktopApp/plans/2026-08-18-chat-composer-enrichment-manual-testing.md b/desktopApp/plans/2026-08-18-chat-composer-enrichment-manual-testing.md new file mode 100644 index 0000000000..b5d4612312 --- /dev/null +++ b/desktopApp/plans/2026-08-18-chat-composer-enrichment-manual-testing.md @@ -0,0 +1,60 @@ +# Desktop Chat Composer Enrichment — Manual Testing Sheet + +**Branch:** `feat/desktop-chat-composer-enrichment` +**Run:** `./gradlew :desktopApp:run`, log in with an account that has NIP-17 DMs, open **Messages**, select a conversation. + +Legend: ☐ untested · ✅ pass · ❌ fail (note issue) + +## P0 — TextFieldValue composer (foundation) +- ☐ Type text; move the caret with arrow keys/click mid-word — cursor position holds. +- ☐ Cmd/Ctrl+Enter sends; Enter alone inserts a newline. + +## P1 — Reply-quoting +- ☐ Hover a message → quote (❝) icon appears; click it → a reply bar shows the quoted author + snippet above the input. +- ☐ Send → recipient sees a threaded reply (reply marker present). +- ☐ Click the ✕ on the reply bar → reply target cleared; next send is a normal message. +- ☐ Reply, then send → reply bar auto-clears after send. + +## P2 — Emoji picker +- ☐ Click the 🙂 (EmojiEmotions) button → grid opens. +- ☐ Search "fire" → grid filters; click 🔥 → inserted at the caret (not appended blindly). +- ☐ Insert an emoji mid-word → lands at the cursor, text splits correctly. +- ☐ Works offline (disable network) — emojis still render via system font. + +## P3 — NIP-30 custom emoji +- ☐ (Requires an emoji pack selected on the account.) Type `:` + a prefix → suggestion strip shows matching pack emojis with images. +- ☐ Click one → `:shortcode:` inserted at the caret. +- ☐ Send → recipient renders the custom emoji (event carries `emoji` tags). +- ☐ No pack selected → no strip appears, no errors. + +## P4 — @mention +- ☐ Type `@` + a name prefix → dropdown of matching cached users. +- ☐ Click one → `nostr:npub…` inserted (with trailing space); dropdown closes. +- ☐ Recipient/link resolves to the mentioned profile. +- ☐ `#hashtag` — typing `#tag` still tags the note on send (no dropdown; deferred — no hashtag index). + +## P5 — Per-message image quality +- ☐ Attach a JPEG/PNG → a "Quality:" chip appears above the input. +- ☐ Send at **Low** vs **Uncompressed** → recipient's received file is visibly smaller at Low (check dimensions/size). +- ☐ Attach an animated GIF → sends animated (byte-identical pass-through), quality chip hidden for GIF-only. +- ☐ Quality override resets to the default after each send. +- ☐ EXIF: a photo with GPS EXIF sent at pass-through has EXIF stripped (desktop opts in). + +## P6 — Nostr-native GIF search +- ☐ Click **GIF** → panel opens, loads recent GIFs from `relay.gifbuddy.lol`. +- ☐ Search "cat" → grid updates (debounced ~350ms). +- ☐ Click a GIF → its URL inserted into the message; send → renders inline/animated for the recipient. +- ☐ Add a second relay via `DesktopPreferences.gifRelays` → results merge, deduped by URL. +- ☐ Point at an unreachable relay → panel degrades to empty/"No GIFs found", no crash, composer still usable. + +## Cross-feature integration +1. ☐ One message mixing a unicode emoji + `:customshortcode:` + `@mention` + `#tag` → sends; recipient renders all; event carries `emoji`, `p`, `t` tags. +2. ☐ Reply to a message and send → reply preserved; `clear()` resets the whole composer. +3. ☐ Switch rooms mid-typing (with an open suggestion) → no stale dropdown / no cross-room leakage. +4. ☐ Compact (narrow) and split (wide) layouts both wire the composer correctly. + +## Regression +- ☐ Plain-text send still works. +- ☐ File attach + drag-and-drop still work. +- ☐ Quick-reaction bar on messages still works. + diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index cd96d371e6..ec3b627398 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -93,6 +93,31 @@ object DesktopPreferences { prefs.put(KEY_PINNED_NAV_ITEMS, value) } + private const val KEY_ENCRYPTED_MEDIA_REAL_TYPE = "encrypted_media_real_type" + + // When false (default), encrypted DM file uploads declare + // application/octet-stream so the Blossom server can't learn the media type + // (image/video/voice/…). When true, they declare the real media type — less + // private, but works with strict servers (blossom.band, primal, …) that + // reject octet-stream. Off by default = privacy-preserving. + var encryptedMediaRealType: Boolean + get() = prefs.getBoolean(KEY_ENCRYPTED_MEDIA_REAL_TYPE, false) + set(value) = prefs.putBoolean(KEY_ENCRYPTED_MEDIA_REAL_TYPE, value) + + private const val KEY_GIF_RELAYS = "gif_relays" + + // Nostr-native GIF search: relays that index NIP-94 (kind 1063) GIF metadata + // (GIF Buddy publishes here). Its dedicated relay.gifbuddy.lol is dead (404), + // so default to general relays that carry these events. User-editable; merged. + private const val DEFAULT_GIF_RELAY = "wss://nos.lol,wss://relay.damus.io" + + var gifRelays: List + get() { + val raw = prefs.get(KEY_GIF_RELAYS, DEFAULT_GIF_RELAY) + return if (raw.isBlank()) emptyList() else raw.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + set(value) = prefs.put(KEY_GIF_RELAYS, value.joinToString(",")) + private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" var sidebarCollapsed: Boolean diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt index b7dc1b5111..4f14ad6d20 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt @@ -45,6 +45,12 @@ object ImageCompressionStore { private val _stripExif = MutableStateFlow(DesktopPreferences.imageStripExif) val stripExif: StateFlow = _stripExif.asStateFlow() + // When true, encrypted DM file uploads declare the real media type instead of + // application/octet-stream — less private, but works with strict Blossom + // servers that reject octet-stream. Off by default (privacy-preserving). + private val _encryptedMediaRealType = MutableStateFlow(DesktopPreferences.encryptedMediaRealType) + val encryptedMediaRealType: StateFlow = _encryptedMediaRealType.asStateFlow() + fun setQuality(quality: CompressionQuality) { _quality.value = quality DesktopPreferences.imageQualityRaw = quality.name @@ -54,4 +60,9 @@ object ImageCompressionStore { _stripExif.value = value DesktopPreferences.imageStripExif = value } + + fun setEncryptedMediaRealType(value: Boolean) { + _encryptedMediaRealType.value = value + DesktopPreferences.encryptedMediaRealType = value + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 3720ed4a64..7fd4690ba1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -20,7 +20,14 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,8 +45,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -53,17 +60,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed @@ -73,6 +83,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -80,19 +92,39 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.desktop.ImageCompressionStore +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.EmojiPickerPanel +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.EmojiSuggestion +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.EmojiSuggestionStrip +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.GifPickerPanel +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.MentionSuggestions +import com.vitorpamplona.amethyst.desktop.ui.chats.composer.standardEmojis import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip +import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText +import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory @@ -102,6 +134,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.awt.datatransfer.DataFlavor @@ -114,6 +147,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val MEDIA_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") +// Reencodable still-image types — gate the quality selector AND per-file +// reencoding on these. GIF is excluded (animated GIFs pass through unchanged); +// AVIF/HEIC are excluded because the reencoder rejects them (it would throw and +// abort the send) — they upload as-is instead. +private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "webp") + /** * Right panel of the DM split-pane layout (flexible width). * @@ -137,6 +176,8 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, + emojiPacks: EmojiPackState? = null, + attachedFiles: SnapshotStateList, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, onBack: (() -> Unit)? = null, @@ -148,12 +189,67 @@ fun ChatPane( val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() + val replyTo by messageState.replyTo.collectAsState() - // File attachment state - val attachedFiles = remember { mutableStateListOf() } + // `:shortcode:` autocomplete: match the word under the caret against the + // account's selected NIP-30 packs AND standard unicode emoji shortcodes. + val myEmojis = + if (emojiPacks != null) { + emojiPacks.myEmojis.collectAsState().value + } else { + emptyList() + } + val currentWord = messageText.currentWord() + val emojiSuggestions = + remember(currentWord, myEmojis) { + if (currentWord.startsWith(":") && currentWord.length > 1) { + val code = currentWord.removePrefix(":") + val custom = + myEmojis + .filter { it.code.startsWith(code, ignoreCase = true) } + .map { EmojiSuggestion(it.code, ":${it.code}:", previewUrl = it.link, previewGlyph = null) } + val standard = + standardEmojis.mapNotNull { emoji -> + val alias = emoji.details.aliases.firstOrNull { it.startsWith(code, ignoreCase = true) } + alias?.let { + EmojiSuggestion(it, emoji.details.string, previewUrl = null, previewGlyph = emoji.details.string) + } + } + (custom + standard).take(30) + } else { + emptyList() + } + } + + // @mention autocomplete: search the cache for users matching the @word under + // the caret (done off the composition to avoid a cache scan every keystroke). + var mentionSuggestions by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(currentWord) { + mentionSuggestions = + if (currentWord.startsWith("@") && currentWord.length > 1) { + cacheProvider.findUsersStartingWith(currentWord.removePrefix("@"), 5) + } else { + emptyList() + } + } + + // File attachment state is hoisted above the lock gate (see + // DeckColumnContainer) so it survives a lock/unlock cycle. var isUploading by remember { mutableStateOf(false) } + // True for the whole duration of a send (upload + publish) — drives the + // send-button spinner. + var isSending by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } + // Image compression: shared global default + optional per-composer override, + // reusing the same store the note composer uses. Override resets after send. + val defaultQuality by ImageCompressionStore.quality.collectAsState() + val stripExifSetting by ImageCompressionStore.stripExif.collectAsState() + val declareRealMimeType by ImageCompressionStore.encryptedMediaRealType.collectAsState() + var qualityOverride by remember { mutableStateOf(null) } + val activeQuality = qualityOverride ?: defaultQuality + val hasImageAttachment = attachedFiles.any { it.extension.lowercase() in IMAGE_EXTENSIONS } + // Helper: attach files fun attachFiles(files: List) { val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } @@ -333,6 +429,7 @@ fun ChatPane( } } }, + onReply = { messageState.setReply(it) }, ) } @@ -362,44 +459,119 @@ fun ChatPane( onPaste = {}, onRemove = { attachedFiles.remove(it) }, ) + // Compression quality selector — only meaningful for reencodable images. + if (hasImageAttachment) { + Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { + QualitySelectorChip( + activeQuality = activeQuality, + isOverride = qualityOverride != null, + onSelect = { qualityOverride = it }, + onReset = { qualityOverride = null }, + ) + } + } + } + + // @mention autocomplete dropdown + MentionSuggestions( + suggestions = mentionSuggestions, + onPick = { user -> + onMentionPicked(messageState, messageText, user) + mentionSuggestions = emptyList() + }, + ) + + // `:shortcode:` autocomplete strip (custom packs + standard emoji) + EmojiSuggestionStrip( + suggestions = emojiSuggestions, + onPick = { suggestion -> + messageState.updateMessage(messageText.replaceCurrentWord(suggestion.insertText)) + }, + ) + + // Reply-quote preview (NIP-17 threaded reply target) + replyTo?.let { replyNote -> + ReplyPreviewBar( + note = replyNote, + account = account, + onDismiss = { messageState.clearReply() }, + ) } // Message input MessageInput( - messageText = messageText.text, + messageText = messageText, recipientsMissingRelays = recipientsMissingRelays, canSend = messageState.canSend || attachedFiles.isNotEmpty(), isUploading = isUploading, + isSending = isSending, hasAttachments = attachedFiles.isNotEmpty(), - onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, + onMessageChange = { messageState.updateMessage(it) }, onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, onSend = { scope.launch { - if (attachedFiles.isNotEmpty()) { - isUploading = true - try { - sendEncryptedFiles( - files = attachedFiles.toList(), - roomKey = roomKey, - account = account, - cacheProvider = cacheProvider, - blossomServers = blossomServers, - ) - attachedFiles.clear() - } catch (e: Exception) { - // Keep files in attachment row for retry - println("Encrypted file send failed: ${e.message}") - } finally { - isUploading = false + isSending = true + try { + if (attachedFiles.isNotEmpty()) { + isUploading = true + try { + sendEncryptedFiles( + files = attachedFiles.toList(), + roomKey = roomKey, + account = account, + cacheProvider = cacheProvider, + blossomServers = blossomServers, + quality = activeQuality, + stripExif = stripExifSetting, + declareRealMimeType = declareRealMimeType, + ) + attachedFiles.clear() + qualityOverride = null + } catch (e: Exception) { + // Keep files in the attachment row for retry and + // surface the reason (console-only failures were + // invisible during testing). + println("Encrypted file send failed: ${e.message}") + val msg = e.message ?: e::class.simpleName.orEmpty() + val hint = + when { + // Server inspects the bytes and rejects the encrypted blob + // no matter what type we declare — it can't host private files. + "does not match" in msg || "expected application/json" in msg -> + " — this media server inspects uploads and can't host encrypted DM files. Switch to nostr.download in Media settings." + "415" in msg || "media type" in msg || "file type" in msg -> + " — this media server rejects private (opaque) uploads. Switch to one that accepts them (e.g. nostr.download), or enable “Reveal media type on encrypted DM uploads” in Media settings." + else -> "" + } + snackbarHostState.showSnackbar("Couldn't send attachment: $msg$hint") + } finally { + isUploading = false + } } - } - // Also send text message if present - if (messageState.canSend) { - if (messageState.send()) { + // Also send text message if present. Wrap in try/catch: + // an uncaught throw here (e.g. a relay/signer error after + // the message was already published) previously skipped + // clear(), leaving the sent text/GIF stuck in the input. + if (messageState.canSend) { + try { + if (messageState.send()) { + messageState.clear() + } + } catch (e: Exception) { + println("DM text send failed: ${e.message}") + // The message may already have been broadcast, so + // clear the composer and report the error rather + // than risk a duplicate on retry. + messageState.clear() + snackbarHostState.showSnackbar( + "Message sent, but a relay reported: ${e.message ?: e::class.simpleName}", + ) + } + } else if (attachedFiles.isEmpty()) { messageState.clear() } - } else if (attachedFiles.isEmpty()) { - messageState.clear() + } finally { + isSending = false } } }, @@ -423,8 +595,13 @@ private fun MessageList( cacheProvider: ICacheProvider, onAuthorClick: (String) -> Unit, onReaction: (Note, String) -> Unit = { _, _ -> }, + onReply: (Note) -> Unit = {}, ) { val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + // Message the user just jumped to via a reply preview — briefly highlighted. + var highlightedNoteId by remember { mutableStateOf(null) } // Auto-scroll to bottom when new messages arrive LaunchedEffect(messages.size) { @@ -433,6 +610,19 @@ private fun MessageList( } } + // Scroll to the referenced message and flash it. No-op if it isn't loaded + // in the current window (older than what's on screen). + fun jumpToMessage(noteId: String) { + val index = messages.indexOfFirst { it.idHex == noteId } + if (index < 0) return + scope.launch { + listState.animateScrollToItem(index) + highlightedNoteId = noteId + delay(1600) + if (highlightedNoteId == noteId) highlightedNoteId = null + } + } + LazyColumn( state = listState, reverseLayout = true, @@ -448,8 +638,12 @@ private fun MessageList( isMe = isMe, isDraft = isDraft, account = account, + cacheProvider = cacheProvider, + isHighlighted = note.idHex == highlightedNoteId, onAuthorClick = onAuthorClick, onReaction = { emoji -> onReaction(note, emoji) }, + onReply = { onReply(note) }, + onReplyContextClick = { parentId -> jumpToMessage(parentId) }, ) } } @@ -477,13 +671,43 @@ private fun MessageWithReactions( isMe: Boolean, isDraft: Boolean, account: IAccount, + cacheProvider: ICacheProvider, + isHighlighted: Boolean = false, onAuthorClick: (String) -> Unit, onReaction: (String) -> Unit, + onReply: () -> Unit = {}, + onReplyContextClick: (String) -> Unit = {}, ) { var isHovered by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) } val showIcon = (isHovered || showPicker) && !isDraft + // Reply-jump highlight: a brief bounce-in scale + background flash when the + // user taps a reply preview that points at this message. + val highlightScale by animateFloatAsState( + targetValue = if (isHighlighted) 1.03f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "replyHighlightScale", + ) + val highlightBg by animateColorAsState( + targetValue = if (isHighlighted) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent, + animationSpec = tween(durationMillis = 250), + label = "replyHighlightBg", + ) + + // The DM cache doesn't link Note.replyTo for chat messages, so resolve the + // replied-to parent from the event's reply e-tag directly. + val replyParent = + remember(note.idHex) { + val replyId = + when (val e = note.event) { + is ChatMessageEvent -> e.replyTo().lastOrNull() + is ChatMessageEncryptedFileHeaderEvent -> e.replyTo().lastOrNull() + else -> null + } + replyId?.let { cacheProvider.getNoteIfExists(it) } + } + // Decrypt NIP-04 content asynchronously; NIP-17 content is already plaintext val event = note.event var decryptedContent by remember(note.idHex) { mutableStateOf(null) } @@ -515,7 +739,15 @@ private fun MessageWithReactions( .onPointerEvent(PointerEventType.Enter) { isHovered = true } .onPointerEvent(PointerEventType.Exit) { isHovered = false }, ) { - Column { + Column( + modifier = + Modifier + .graphicsLayer { + scaleX = highlightScale + scaleY = highlightScale + }.clip(RoundedCornerShape(8.dp)) + .background(highlightBg), + ) { ChatMessageCompose( note = note, isLoggedInUser = isMe, @@ -594,6 +826,23 @@ private fun MessageWithReactions( } } + // Reply icon: same reserve-space + fade-on-hover treatment as + // the reaction button. Sets the composer's reply target. + Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) { + IconButton( + onClick = onReply, + enabled = showIcon, + modifier = Modifier.size(20.dp), + ) { + Icon( + symbol = MaterialSymbols.Reply, + contentDescription = "Reply", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // AddReaction icon: always laid out to reserve space (so hover // doesn't reflow the detail row and shift the whole list); // alpha fades in/out based on hover. @@ -630,18 +879,39 @@ private fun MessageWithReactions( } }, ) { _ -> - when (note.event) { - is ChatMessageEncryptedFileHeaderEvent -> { - ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent) + Column { + // Replied-to message preview (tap to jump + highlight). + replyParent?.let { parent -> + MessageReplyContext( + parent = parent, + account = account, + onClick = { onReplyContextClick(parent.idHex) }, + ) } + when (note.event) { + is ChatMessageEncryptedFileHeaderEvent -> { + ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent) + } - else -> { - SelectionContainer { - if (decryptedContent != null) { - Text( - text = decryptedContent!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + else -> { + val content = decryptedContent + if (content != null) { + // Rich rendering so nostr:npub mentions resolve to + // names, and image/GIF URLs (incl. picked GIFs) and + // custom emoji render inline instead of as raw text. + val tags = + remember(note.idHex) { + note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList + } + val richState = + remember(content, tags) { + CachedRichTextParser.parseText(content, tags) + } + DesktopRichText( + content = content, + state = richState, + localCache = cacheProvider as? DesktopLocalCache, + callbacks = RichTextCallbacks(onMentionClick = onAuthorClick), ) } else { Text( @@ -661,6 +931,71 @@ private fun MessageWithReactions( } } +/** + * Small "replying to …" preview shown at the top of a reply message bubble. + * Shows the quoted author + a snippet; tapping it asks the list to jump to and + * highlight the original message. Mirrors the reply chips in Signal/WhatsApp/ + * Telegram. + */ +@Composable +private fun MessageReplyContext( + parent: Note, + account: IAccount, + onClick: () -> Unit, +) { + var preview by remember(parent.idHex) { mutableStateOf("") } + LaunchedEffect(parent.idHex) { + preview = + when (val e = parent.event) { + is ChatMessageEncryptedFileHeaderEvent -> "📎 Attachment" + is PrivateDmEvent -> + try { + e.decryptContent(account.signer) + } catch (_: Exception) { + "" + } + + else -> e?.content ?: "" + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(bottom = 4.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Box( + modifier = + Modifier + .width(3.dp) + .height(28.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.primary), + ) + Spacer(Modifier.width(6.dp)) + Column { + Text( + text = parent.author?.toBestDisplayName() ?: "Unknown", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + /** * Sends a NIP-17 gift-wrapped reaction to a note within a group DM. */ @@ -724,20 +1059,98 @@ private fun ReactionBar(onReaction: (String) -> Unit) { } } +/** + * Preview bar shown above the composer when a message is selected as the reply + * target. Mirrors Android's DisplayReplyingToNote: quoted author + snippet + + * dismiss. On send, [ChatNewMessageState] threads the reply via + * ChatMessageEvent.reply(). + */ +@Composable +private fun ReplyPreviewBar( + note: Note, + account: IAccount, + onDismiss: () -> Unit, +) { + val event = note.event + var preview by remember(note.idHex) { mutableStateOf("") } + LaunchedEffect(note.idHex, event) { + preview = + when (event) { + is ChatMessageEncryptedFileHeaderEvent -> "📎 Attachment" + is PrivateDmEvent -> + try { + event.decryptContent(account.signer) + } catch (_: Exception) { + "" + } + + else -> event?.content ?: "" + } + } + + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Icon( + symbol = MaterialSymbols.Reply, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = note.author?.toBestDisplayName() ?: "Unknown", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(28.dp), + ) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = "Cancel reply", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + /** * Message input area at the bottom of the chat pane. */ @Composable private fun MessageInput( - messageText: String, + messageText: TextFieldValue, recipientsMissingRelays: Boolean, canSend: Boolean, isUploading: Boolean = false, + isSending: Boolean = false, hasAttachments: Boolean = false, - onMessageChange: (String) -> Unit, + onMessageChange: (TextFieldValue) -> Unit, onAttach: () -> Unit = {}, onSend: () -> Unit, ) { + var showEmojiPicker by remember { mutableStateOf(false) } + var showGifPicker by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -799,7 +1212,7 @@ private fun MessageInput( interactionSource = messageInteraction, decorationBox = { innerTextField -> androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox( - value = messageText, + value = messageText.text, innerTextField = innerTextField, enabled = true, singleLine = false, @@ -828,21 +1241,91 @@ private fun MessageInput( }, ) + // Emoji picker + Box { + IconButton( + onClick = { showEmojiPicker = !showEmojiPicker }, + enabled = !isUploading, + modifier = Modifier.size(40.dp), + ) { + Icon( + symbol = MaterialSymbols.EmojiEmotions, + contentDescription = "Emoji", + tint = MaterialTheme.colorScheme.primary, + ) + } + if (showEmojiPicker) { + Popup( + alignment = Alignment.BottomEnd, + offset = IntOffset(0, -48), + onDismissRequest = { showEmojiPicker = false }, + properties = PopupProperties(focusable = true), + ) { + EmojiPickerPanel( + onPick = { emoji -> + onMessageChange(insertAtCursor(messageText, emoji)) + }, + ) + } + } + } + + // GIF picker (Nostr-native, NIP-94) + Box { + TextButton( + onClick = { showGifPicker = !showGifPicker }, + enabled = !isUploading, + modifier = Modifier.size(width = 44.dp, height = 40.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(0.dp), + ) { + Text( + text = "GIF", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (showGifPicker) { + Popup( + alignment = Alignment.BottomEnd, + offset = IntOffset(0, -48), + onDismissRequest = { showGifPicker = false }, + properties = PopupProperties(focusable = true), + ) { + GifPickerPanel( + onPick = { url -> + onMessageChange(messageText.insertUrlAtCursor(url)) + showGifPicker = false + }, + ) + } + } + } + IconButton( onClick = onSend, - enabled = canSend && !isUploading, + enabled = canSend && !isSending, modifier = Modifier.size(40.dp), ) { - Icon( - MaterialSymbols.AutoMirrored.Send, - contentDescription = "Send", - tint = - if (canSend && !isUploading) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - }, - ) + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Send, + contentDescription = "Send", + tint = + if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } } } @@ -876,6 +1359,33 @@ private fun MessageInput( } } +/** + * Insert [insert] at the caret (replacing any selection) and place the caret + * immediately after it. Shared by the emoji picker, custom-emoji / mention + * autocomplete, and GIF picker to write into the composer's TextFieldValue. + */ +internal fun insertAtCursor( + current: TextFieldValue, + insert: String, +): TextFieldValue { + val sel = current.selection + val newText = current.text.replaceRange(sel.min, sel.max, insert) + val cursor = sel.min + insert.length + return TextFieldValue(text = newText, selection = TextRange(cursor)) +} + +/** + * Replaces the `@word` under the caret with a `nostr:npub…` reference (plus a + * trailing space), mirroring the note composer's mention insertion. + */ +private fun onMentionPicked( + messageState: ChatNewMessageState, + current: TextFieldValue, + user: User, +) { + messageState.updateMessage(current.replaceCurrentWord("nostr:${user.pubkeyNpub()} ")) +} + /** * Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15) * wrapped in GiftWrap for each recipient. @@ -886,6 +1396,9 @@ private suspend fun sendEncryptedFiles( account: IAccount, cacheProvider: ICacheProvider, blossomServers: StateFlow>?, + quality: CompressionQuality? = null, + stripExif: Boolean = true, + declareRealMimeType: Boolean = false, ) { val orchestrator = UploadOrchestrator() val server = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER @@ -893,7 +1406,12 @@ private suspend fun sendEncryptedFiles( for (file in files) { val cipher = AESGCM() - val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + // Only re-encode genuinely reencodable raster images; everything else + // (video, audio, GIF, AVIF/HEIC, unknown) uploads unchanged so a single + // unsupported file can't abort the whole send. + val fileQuality = if (file.extension.lowercase() in IMAGE_EXTENSIONS) quality else null + val result = + orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType) val url = result.blossom.url ?: continue val template = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index b554461d64..3ecb9c9b8c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -73,6 +75,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.awt.Cursor +import java.io.File private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -93,14 +96,17 @@ fun DesktopMessagesScreen( localCache: DesktopLocalCache, compactMode: Boolean = false, onNavigateToProfile: (String) -> Unit = {}, + // Hoisted above the lock gate (see DeckColumnContainer) so a lock/unlock + // cycle preserves the selected conversation + draft + attachments. + // messageState is null only when no room is selected. + listState: ChatroomListState, + emojiPacks: EmojiPackState, + messageState: ChatNewMessageState?, + attachedFiles: SnapshotStateList, ) { val scope = rememberCoroutineScope() val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current var showDmRelayPicker by remember { mutableStateOf(false) } - val listState = - remember(account) { - ChatroomListState(account, cacheProvider, relayManager, localCache, scope) - } val selectedRoom by listState.selectedRoom.collectAsState() val listFocusRequester = remember { FocusRequester() } var showNewDmDialog by remember { mutableStateOf(false) } @@ -158,6 +164,9 @@ fun DesktopMessagesScreen( account = account, cacheProvider = cacheProvider, scope = scope, + emojiPacks = emojiPacks, + messageState = messageState, + attachedFiles = attachedFiles, onNavigateToProfile = onNavigateToProfile, listFocusRequester = listFocusRequester, onShowNewDm = { showNewDmDialog = true }, @@ -171,6 +180,9 @@ fun DesktopMessagesScreen( account = account, cacheProvider = cacheProvider, scope = scope, + emojiPacks = emojiPacks, + messageState = messageState, + attachedFiles = attachedFiles, onNavigateToProfile = onNavigateToProfile, listFocusRequester = listFocusRequester, onShowNewDm = { showNewDmDialog = true }, @@ -242,6 +254,9 @@ private fun CompactMessagesContent( account: IAccount, cacheProvider: ICacheProvider, scope: CoroutineScope, + emojiPacks: EmojiPackState, + messageState: ChatNewMessageState?, + attachedFiles: SnapshotStateList, onNavigateToProfile: (String) -> Unit, listFocusRequester: FocusRequester, onShowNewDm: () -> Unit, @@ -250,23 +265,11 @@ private fun CompactMessagesContent( ) { Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { val currentRoom = selectedRoom - if (currentRoom != null) { + if (currentRoom != null && messageState != null) { val feedViewModel = remember(currentRoom) { ChatroomFeedViewModel(currentRoom, account, cacheProvider) } - val messageState = - remember(currentRoom) { - ChatNewMessageState( - account, - cacheProvider, - scope, - dmInboxResolver = - (account as? DesktopIAccount)?.dmInboxResolver?.let { resolver -> - { hexKey -> resolver.resolve(hexKey) } - }, - ) - } val broadcastStatus = if (account is DesktopIAccount) { account.dmSendTracker.status @@ -282,6 +285,8 @@ private fun CompactMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + emojiPacks = emojiPacks, + attachedFiles = attachedFiles, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, onBack = { listState.clearSelection() }, @@ -311,6 +316,9 @@ private fun SplitMessagesContent( account: IAccount, cacheProvider: ICacheProvider, scope: CoroutineScope, + emojiPacks: EmojiPackState, + messageState: ChatNewMessageState?, + attachedFiles: SnapshotStateList, onNavigateToProfile: (String) -> Unit, listFocusRequester: FocusRequester, onShowNewDm: () -> Unit, @@ -354,23 +362,11 @@ private fun SplitMessagesContent( .background(MaterialTheme.colorScheme.surface), ) { val currentRoom = selectedRoom - if (currentRoom != null) { + if (currentRoom != null && messageState != null) { val feedViewModel = remember(currentRoom) { ChatroomFeedViewModel(currentRoom, account, cacheProvider) } - val messageState = - remember(currentRoom) { - ChatNewMessageState( - account, - cacheProvider, - scope, - dmInboxResolver = - (account as? DesktopIAccount)?.dmInboxResolver?.let { resolver -> - { hexKey -> resolver.resolve(hexKey) } - }, - ) - } val broadcastStatus = if (account is DesktopIAccount) { account.dmSendTracker.status @@ -386,6 +382,8 @@ private fun SplitMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + emojiPacks = emojiPacks, + attachedFiles = attachedFiles, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiPickerPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiPickerPanel.kt new file mode 100644 index 0000000000..01cfb4f686 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiPickerPanel.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A compact unicode emoji picker for the desktop DM composer. Renders emojis as + * text via the platform's system emoji font (offline, no network) using the + * Kodein Emoji.kt data API. [onPick] receives the UTF-16 emoji string to insert + * at the caret. + */ +@Composable +fun EmojiPickerPanel( + onPick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + + // Shared flat list of all emojis (built once, static emoji-kt data — no + // network/service init needed). + val filtered = + remember(query) { + if (query.isBlank()) { + standardEmojis + } else { + val q = query.trim().lowercase() + standardEmojis.filter { + it.details.description + .lowercase() + .contains(q) + } + } + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + tonalElevation = 2.dp, + modifier = modifier.size(width = 320.dp, height = 360.dp), + ) { + Column(modifier = Modifier.padding(8.dp)) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + placeholder = { Text("Search emoji…") }, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + + if (filtered.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No emoji found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 36.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + items(filtered, key = { it.details.description }) { emoji -> + Text( + text = emoji.details.string, + fontSize = 22.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .size(36.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { onPick(emoji.details.string) }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiSuggestion.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiSuggestion.kt new file mode 100644 index 0000000000..5aa8e45020 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/EmojiSuggestion.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import org.kodein.emoji.Emoji +import org.kodein.emoji.allGroups +import org.kodein.emoji.allOf + +/** + * One `:shortcode:` autocomplete entry — either a NIP-30 custom-pack emoji + * (rendered from [previewUrl], inserts `:code:`) or a standard unicode emoji + * (rendered as [previewGlyph], inserts the glyph itself). + */ +data class EmojiSuggestion( + val label: String, + val insertText: String, + val previewUrl: String?, + val previewGlyph: String?, +) + +/** + * The full flat list of standard unicode emojis, built once from the Kodein + * emoji-kt static data (no network/service init needed). Shared by the emoji + * picker and the `:` shortcode autocomplete. + */ +internal val standardEmojis: List by lazy { + Emoji.allGroups().flatMap { group -> Emoji.allOf(group) } +} + +/** + * Horizontal `:shortcode:` autocomplete strip shown above the composer while the + * word under the caret starts with `:`. Renders custom-pack emojis (image) and + * standard emojis (glyph) side by side. [onPick] returns the chosen entry so the + * caller can replace the current word with its [EmojiSuggestion.insertText]. + */ +@Composable +fun EmojiSuggestionStrip( + suggestions: List, + onPick: (EmojiSuggestion) -> Unit, + modifier: Modifier = Modifier, +) { + if (suggestions.isEmpty()) return + + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = modifier.fillMaxWidth(), + ) { + LazyRow( + modifier = Modifier.fillMaxWidth().heightIn(min = 44.dp).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + items(suggestions, key = { "${it.label}|${it.insertText}" }) { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 4.dp) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { onPick(suggestion) }, + ) { + if (suggestion.previewUrl != null) { + AsyncImage( + model = suggestion.previewUrl, + contentDescription = suggestion.label, + modifier = Modifier.size(28.dp).padding(end = 4.dp), + ) + } else if (suggestion.previewGlyph != null) { + Text( + text = suggestion.previewGlyph, + fontSize = 20.sp, + modifier = Modifier.padding(end = 4.dp), + ) + } + Text( + text = ":${suggestion.label}:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(end = 4.dp), + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt new file mode 100644 index 0000000000..66fff8317f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +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.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager +import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage +import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import kotlinx.coroutines.delay + +/** + * Nostr-native GIF search picker for the DM composer. Queries the configured + * NIP-94 GIF relays (default GIF Buddy) via the app's own relay client and + * inserts the chosen GIF's hosted URL into the message. No third-party GIF API. + */ +@Composable +fun GifPickerPanel( + onPick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val relayManager = LocalRelayManager.current + var query by remember { mutableStateOf("") } + var results by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(false) } + + // Debounced search: re-run ~350ms after the query settles. A blank query + // loads the newest GIFs from the relays. + LaunchedEffect(query, relayManager) { + val client = relayManager?.client + if (client == null) { + results = emptyList() + return@LaunchedEffect + } + loading = true + delay(350) + results = searchGifs(client, DesktopPreferences.gifRelays, query) + loading = false + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + tonalElevation = 2.dp, + modifier = modifier.size(width = 340.dp, height = 380.dp), + ) { + Column(modifier = Modifier.padding(8.dp)) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + placeholder = { Text("Search GIFs…") }, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + + Box(modifier = Modifier.fillMaxSize().padding(top = 4.dp), contentAlignment = Alignment.Center) { + when { + loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) + results.isEmpty() -> + Text( + "No GIFs found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(results, key = { it.url }) { gif -> + val cellModifier = + Modifier + .padding(2.dp) + .fillMaxWidth() + .aspectRatio(1f) + .pointerHoverIcon(PointerIcon.Hand) + .clickable { onPick(gif.url) } + // Animate GIFs (the whole grid is GIFs); fall back + // to a static frame for any non-.gif URL. + if (isAnimatedGifUrl(gif.url)) { + AnimatedGifImage( + url = gif.url, + contentDescription = gif.description, + modifier = cellModifier, + ) + } else { + AsyncImage( + model = gif.url, + contentDescription = gif.description, + modifier = cellModifier, + ) + } + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt new file mode 100644 index 0000000000..f8eaa49707 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent + +/** A single GIF result surfaced from a NIP-94 (kind 1063) metadata event. */ +data class GifResult( + val url: String, + val description: String, +) + +/** + * Nostr-native GIF search. Queries the given [relays] for NIP-94 kind-1063 + * file-metadata events tagged `m=image/gif` (the shape GIF Buddy publishes), + * merges + dedups by URL across relays, and filters client-side by [query] + * (the default relay has no NIP-50, so matching is done here). No third-party + * GIF API, no API key — just relay REQs on the app's own (Tor-aware) client. + */ +suspend fun searchGifs( + client: INostrClient, + relays: List, + query: String, + limitPerRelay: Int = 100, +): List { + val trimmed = query.trim() + val filter = + Filter( + kinds = listOf(FileHeaderEvent.KIND), + tags = mapOf("m" to listOf("image/gif")), + limit = limitPerRelay, + search = trimmed.ifBlank { null }, + ) + + val raw = mutableListOf() + for (relay in relays) { + val events = + runCatching { client.fetchAll(relay, filter, idleTimeoutMs = 8_000L) } + .getOrDefault(emptyList()) + for (event in events) { + val fileHeader = event as? FileHeaderEvent ?: continue + val url = fileHeader.url() ?: continue + raw.add(GifResult(url = url, description = fileHeader.content)) + } + } + return mergeGifResults(raw, trimmed) +} + +/** + * Dedups [raw] GIF results by URL (keeping first-seen order — newest-first since + * fetchAll sorts descending) and, when [query] is non-blank, filters to entries + * whose description or URL contains it (case-insensitive). Pure so it can be + * unit-tested without a relay client. + */ +internal fun mergeGifResults( + raw: List, + query: String, +): List { + val byUrl = LinkedHashMap() + for (gif in raw) { + if (!byUrl.containsKey(gif.url)) byUrl[gif.url] = gif + } + val all = byUrl.values.toList() + val trimmed = query.trim() + return if (trimmed.isBlank()) { + all + } else { + all.filter { + it.description.contains(trimmed, ignoreCase = true) || + it.url.contains(trimmed, ignoreCase = true) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/MentionSuggestions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/MentionSuggestions.kt new file mode 100644 index 0000000000..cf5f650839 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/MentionSuggestions.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar + +/** + * `@mention` autocomplete dropdown for the desktop DM composer. Mirrors the + * note composer's mention list (ComposeNoteDialog). [onPick] receives the chosen + * user so the caller can insert a `nostr:npub…` reference at the caret. + */ +@Composable +fun MentionSuggestions( + suggestions: List, + onPick: (User) -> Unit, + modifier: Modifier = Modifier, +) { + if (suggestions.isEmpty()) return + + Surface( + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + modifier = modifier.fillMaxWidth(), + ) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp)) { + items(suggestions, key = { it.pubkeyHex }) { user -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .pointerHoverIcon(PointerIcon.Hand) + .clickable { onPick(user) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 28.dp, + contentDescription = null, + ) + Column { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = user.pubkeyNpub().take(24) + "…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 1377340d8f..943bf76ed1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.SettingsScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager @@ -79,6 +81,7 @@ import com.vitorpamplona.amethyst.desktop.ui.SearchScreen import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.ChatroomListState import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen import com.vitorpamplona.amethyst.desktop.ui.scheduledposts.DraftsAndScheduledScreen @@ -398,6 +401,33 @@ internal fun RootContent( } DeckColumnType.Messages -> { + // Messages state is hoisted ABOVE the lock gate so a lock/unlock + // cycle (which tears down the gate's content) preserves the selected + // conversation and the in-progress draft. + val listState = + remember(iAccount) { + ChatroomListState(iAccount, localCache, relayManager, localCache, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val emojiPacks = remember(iAccount) { EmojiPackState(iAccount.signer, localCache, scope) } + val messageState = + remember(selectedRoom) { + selectedRoom?.let { + ChatNewMessageState( + iAccount, + localCache, + scope, + dmInboxResolver = + iAccount.dmInboxResolver?.let { resolver -> + { hexKey -> resolver.resolve(hexKey) } + }, + emojiPacks = emojiPacks, + ) + } + } + // Pending attachments are hoisted here too so a lock/unlock cycle + // doesn't drop them. Keyed on the room so switching chats starts clean. + val attachedFiles = remember(selectedRoom) { mutableStateListOf() } com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate( onOpenSettings = onNavigateToRelays, ) { @@ -408,6 +438,10 @@ internal fun RootContent( localCache = localCache, compactMode = compactMode, onNavigateToProfile = onNavigateToProfile, + listState = listState, + emojiPacks = emojiPacks, + messageState = messageState, + attachedFiles = attachedFiles, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt index af5e3ea951..805caa1339 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.desktop.ImageCompressionStore fun ImageCompressionSettings(modifier: Modifier = Modifier) { val quality by ImageCompressionStore.quality.collectAsState() val stripExif by ImageCompressionStore.stripExif.collectAsState() + val encryptedMediaRealType by ImageCompressionStore.encryptedMediaRealType.collectAsState() Column(modifier = modifier.fillMaxWidth()) { Text( @@ -104,5 +105,40 @@ fun ImageCompressionSettings(modifier: Modifier = Modifier) { onCheckedChange = ImageCompressionStore::setStripExif, ) } + + Spacer(Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Reveal media type on encrypted DM uploads", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(2.dp)) + Text( + "Off (recommended): private DM attachments upload as opaque data, so the media " + + "server can't tell an image from a video or a voice note. This needs a server " + + "that accepts opaque uploads — the default (nostr.download) does.\n\n" + + "Turn on only if your media server rejects opaque uploads with a 415 error. " + + "It declares the real file type so those servers accept the upload, at the " + + "cost of the server seeing the media category (image / video / audio). File " + + "contents stay encrypted either way.\n\n" + + "Note: some servers (e.g. blossom.band) inspect the bytes and reject the " + + "encrypted blob in BOTH modes — they can't host private DM files at all. Use " + + "nostr.download for those.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(0.dp)) + Switch( + checked = encryptedMediaRealType, + onCheckedChange = ImageCompressionStore::setEncryptedMediaRealType, + ) + } } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/InsertAtCursorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/InsertAtCursorTest.kt new file mode 100644 index 0000000000..4881239bb3 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/InsertAtCursorTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlin.test.Test +import kotlin.test.assertEquals + +class InsertAtCursorTest { + @Test + fun insertsAtCaretAndMovesCursorAfter() { + val result = insertAtCursor(TextFieldValue("ab", TextRange(1)), "X") + assertEquals("aXb", result.text) + assertEquals(TextRange(2), result.selection) + } + + @Test + fun insertsAtEnd() { + val result = insertAtCursor(TextFieldValue("hi ", TextRange(3)), "🔥") + assertEquals("hi 🔥", result.text) + assertEquals(3 + "🔥".length, result.selection.start) + } + + @Test + fun replacesSelection() { + // Selecting "bc" (range 1..3) and inserting "X" replaces the selection. + val result = insertAtCursor(TextFieldValue("abcd", TextRange(1, 3)), "X") + assertEquals("aXd", result.text) + assertEquals(TextRange(2), result.selection) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResultTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResultTest.kt new file mode 100644 index 0000000000..b8f129540b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResultTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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.desktop.ui.chats.composer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GifResultTest { + private fun gif( + url: String, + desc: String = "", + ) = GifResult(url, desc) + + @Test + fun dedupsByUrlKeepingFirstSeenOrder() { + val raw = + listOf( + gif("https://host/a.gif", "cat"), + gif("https://host/b.gif", "dog"), + gif("https://host/a.gif", "cat duplicate"), + ) + val result = mergeGifResults(raw, "") + assertEquals(2, result.size) + assertEquals("https://host/a.gif", result[0].url) + assertEquals("cat", result[0].description) // first-seen wins + assertEquals("https://host/b.gif", result[1].url) + } + + @Test + fun blankQueryReturnsAll() { + val raw = listOf(gif("https://host/a.gif", "cat"), gif("https://host/b.gif", "dog")) + assertEquals(2, mergeGifResults(raw, " ").size) + } + + @Test + fun filtersByDescriptionCaseInsensitive() { + val raw = listOf(gif("https://host/a.gif", "Happy Cat"), gif("https://host/b.gif", "dog")) + val result = mergeGifResults(raw, "cat") + assertEquals(1, result.size) + assertEquals("https://host/a.gif", result[0].url) + } + + @Test + fun filtersByUrlWhenDescriptionEmpty() { + val raw = listOf(gif("https://host/thumbs-up.gif", ""), gif("https://host/b.gif", "dog")) + val result = mergeGifResults(raw, "thumbs") + assertEquals(1, result.size) + assertTrue(result[0].url.contains("thumbs")) + } + + @Test + fun noMatchReturnsEmpty() { + val raw = listOf(gif("https://host/a.gif", "cat")) + assertTrue(mergeGifResults(raw, "zebra").isEmpty()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4eba450a5c..73ddab6fc4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ jacksonModuleKotlin = "2.22.1" javaKeyring = "1.0.4" kmpTorRuntime = "2.6.0" kmpTorResource = "409.5.0" +kodeinEmoji = "2.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.4.10" @@ -189,6 +190,7 @@ jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackso java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } kmp-tor-runtime = { group = "io.matthewnelson.kmp-tor", name = "runtime", version.ref = "kmpTorRuntime" } kmp-tor-resource-exec-tor = { group = "io.matthewnelson.kmp-tor", name = "resource-exec-tor", version.ref = "kmpTorResource" } +kodein-emoji-compose-m3 = { group = "org.kodein.emoji", name = "emoji-compose-m3", version.ref = "kodeinEmoji" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }