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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-08-19 10:59:21 +03:00
co-authored by Claude Opus 4.8
parent 89835da7e5
commit 84a031b931
20 changed files with 1584 additions and 115 deletions
@@ -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")
@@ -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<NormalizedRelayUrl>?)? = 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<TextFieldValue> = _message.asStateFlow()
@@ -208,6 +217,7 @@ class ChatNewMessageState(
val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>()
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) }
}
}
@@ -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")
}
}
}
}
+3
View File
@@ -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
@@ -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.
</content>
@@ -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<String>
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
@@ -45,6 +45,12 @@ object ImageCompressionStore {
private val _stripExif = MutableStateFlow(DesktopPreferences.imageStripExif)
val stripExif: StateFlow<Boolean> = _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<Boolean> = _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
}
}
@@ -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<File>,
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<File>() }
// `: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<List<User>>(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<CompressionQuality?>(null) }
val activeQuality = qualityOverride ?: defaultQuality
val hasImageAttachment = attachedFiles.any { it.extension.lowercase() in IMAGE_EXTENSIONS }
// Helper: attach files
fun attachFiles(files: List<File>) {
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<String?>(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<String?>(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<List<String>>?,
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 =
@@ -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<File>,
) {
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<File>,
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<File>,
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,
)
@@ -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) },
)
}
}
}
}
}
}
@@ -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<Emoji> 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<EmojiSuggestion>,
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),
)
}
}
}
}
}
@@ -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<List<GifResult>>(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,
)
}
}
}
}
}
}
}
}
@@ -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<String>,
query: String,
limitPerRelay: Int = 100,
): List<GifResult> {
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<GifResult>()
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<GifResult>,
query: String,
): List<GifResult> {
val byUrl = LinkedHashMap<String, GifResult>()
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)
}
}
}
@@ -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<User>,
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,
)
}
}
}
}
}
}
@@ -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<java.io.File>() }
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,
)
}
}
@@ -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,
)
}
}
}
@@ -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)
}
}
@@ -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())
}
}
+2
View File
@@ -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" }