mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
89835da7e5
commit
84a031b931
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
+12
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
-31
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+25
@@ -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
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+572
-54
@@ -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 =
|
||||
|
||||
+28
-30
@@ -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,
|
||||
)
|
||||
|
||||
+129
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user