aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-10 07:35:16 -0700
committerAdam Soltys <[email protected]>2026-10-10 07:35:16 -0700
commit79b09acc3c4c471fb29bb8d6994c26e849b90bb9 (patch)
treeb9993893b5f88bc7e4d3277eeada103556dac12b /src/features
parente6a65e9921bc0e61ea17459e4bd12f64baf0c3b8 (diff)
downloadcoinosv3-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.js37
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 ---------------------------------------------------------------