diff options
| author | Adam Soltys <[email protected]> | 2026-10-10 07:35:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-10 07:35:16 -0700 |
| commit | 79b09acc3c4c471fb29bb8d6994c26e849b90bb9 (patch) | |
| tree | b9993893b5f88bc7e4d3277eeada103556dac12b /src/features | |
| parent | e6a65e9921bc0e61ea17459e4bd12f64baf0c3b8 (diff) | |
| download | coinosv3-79b09acc3c4c471fb29bb8d6994c26e849b90bb9.tar.gz coinosv3-79b09acc3c4c471fb29bb8d6994c26e849b90bb9.zip | |
GIF search, from nostr.build's GIF index (gifs.nostr.build). A GIF button in the post composer and in every chat composer (DMs, groups, communities) opens a picker: "GIFs from nostr.build" (linked, as their terms require), starter chips (gm, lol, yes, wow, …; the first runs on open), a search field (debounced 250 ms, no one-letter searches, one shared request per normalised query, never aborted, 429/403/503 answered in words), and a masonry grid whose tiles play the small H.264 copy only while near the screen, over the lqip blur, falling back to the animated preview (stills under reduced motion). A pick puts the original URL in a post — published with a NIP-92 imeta (m, dim, size, alt) — or, in a chat, sends it at once after whatever was typed. Media URLs not on gifs.nostr.build are dropped. Browser calls carry no headers: production needs v3.coinos.io registered in the nostr.build dashboard (localhost works as their shared dev client). tools/gif-picker-test.js runs against the live API.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 37 |
1 files changed, 31 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index a3452ee..cab7a97 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -43,6 +43,7 @@ import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { sha256 } from '@noble/hashes/sha256'; import { base64urlnopad } from '@scure/base'; import { t, getLang } from '../i18n.js'; +import { createGifPicker, gifMedia } from '../gif-picker.js'; import { inLanguage, isMachinePost, guessLang } from '../lang-guess.js'; import { translatePieces, translatable as transParts } from '../translate-split.js'; import { animateZap, warmZapSound } from '../zap-animation.js'; @@ -82,6 +83,10 @@ const OPEN_VIEW = (() => { export function messagesFeature(ctx) { const { h, ui, render, wallet, toast, hook } = ctx; + const gifs = createGifPicker({ h, t, ui, render }); + // NIP-92: an attachment described, so clients needn't sniff the URL + const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), + ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -9796,8 +9801,7 @@ export function messagesFeature(ctx) { render(); try { // NIP-92: what we uploaded, described, so clients needn't sniff the URL - const imeta = media.filter((m) => m && m.url) - .map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]); + const imeta = media.filter((m) => m && m.url).map(mediaImeta); // NIP-18 quote: the reference is already in the text; the tags let a // client render the quoted post inline and tell its author const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) @@ -9833,8 +9837,7 @@ export function messagesFeature(ctx) { ])].filter((pk) => pk !== id.pubkey).slice(0, 8); // and whoever the reply mentions by name (NIP-27), beyond the thread's eight for (const pk of mentionedPks(text)) if (pk !== id.pubkey && !pTags.includes(pk)) pTags.push(pk); - const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url)) - .map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]); + const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url)).map(mediaImeta); const root = c.root || (target.id === rootId ? target : null); // the root's scope: from the root itself, or — not loaded yet — copied // off the comment being answered, which carries it @@ -10726,10 +10729,22 @@ export function messagesFeature(ctx) { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), + gifs.button('post', 'attach-btn gif-btn'), h('div', { class: 'row gap6 composer-options' }, previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))), - postCancelWarning()); + postCancelWarning(), + gifs.sheet('post', (g) => { + const cur = composeText().replace(/\s+$/, ''); + const next = (cur ? cur + '\n' : '') + g.url; + ui.profCompose = next; + setDraft(POST_DRAFT, next); + (ui.postMedia ||= []).push(gifMedia(g)); + ui.postPreview = true; + const el = document.querySelector('.post-input'); + if (el) el.value = next; + render(); + })); } // ---- the feed view -------------------------------------------------------- @@ -12431,7 +12446,17 @@ export function messagesFeature(ctx) { // animation, so re-stick once now and once when it has settled. onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); }, }), - h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend')))); + gifs.button('chat:' + draftKey, 'iconbtn chat-gif'), + h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend'))), + // a picked GIF goes out at once, after whatever was typed + gifs.sheet('chat:' + draftKey, (g) => { + const cur = draftFor(draftKey).replace(/\s+$/, ''); + const next = (cur ? cur + ' ' : '') + g.url; + setDraft(draftKey, next); + const el = document.getElementById('msg-draft'); + if (el) el.value = next; + onSend(); + })); }; // ---- home --------------------------------------------------------------- |
