aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js37
-rw-r--r--src/gif-picker.js192
-rw-r--r--src/i18n.js6
-rw-r--r--src/style.css23
4 files changed, 250 insertions, 8 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 ---------------------------------------------------------------
diff --git a/src/gif-picker.js b/src/gif-picker.js
new file mode 100644
index 0000000..a2795e2
--- /dev/null
+++ b/src/gif-picker.js
@@ -0,0 +1,192 @@
+// GIF search, from nostr.build's free GIF index (gifs.nostr.build). A web
+// client is admitted by its Origin, registered once in their dashboard;
+// localhost is admitted as a shared development client. The rules this
+// follows are theirs (https://gifs.nostr.build/developers/llms-full.txt):
+// no headers from the browser, "GIFs from nostr.build" shown wherever the
+// GIFs are, one request per query (shared, never aborted), debounced typing,
+// no one-letter searches, grid tiles play the small H.264 copy (`mp4`) and
+// fall back to the animated preview, and the note gets the original `url`
+// with a NIP-92 imeta. Media URLs other than gifs.nostr.build are dropped.
+
+export const GIF_API = 'https://gifs.nostr.build/api/v1';
+export const GIF_STARTERS = ['gm', 'lol', 'yes', 'wow', 'applause', 'facepalm', 'love', 'fire'];
+const MEDIA_RE = /^https:\/\/gifs\.nostr\.build\//;
+const LIMIT = 60;
+
+// The server's own normalisation, near enough to key the cache by.
+export const normGifQuery = (q) => String(q || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 100);
+// A single Latin letter is noise; one emoji, one CJK/Hangul character, or
+// any two characters are worth a search. No letter, digit or emoji: nothing.
+export function gifQueryOk(q) {
+ const s = normGifQuery(q);
+ if (!/[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(s)) return false;
+ if ([...s].length === 1 && /[a-z]/i.test(s)) return false;
+ return true;
+}
+
+const safeMedia = (u) => (typeof u === 'string' && MEDIA_RE.test(u) ? u : null);
+// Only what the picker and the note need, every URL checked.
+export function cleanGif(g) {
+ if (!g || !safeMedia(g.url) || !(g.width > 0) || !(g.height > 0)) return null;
+ const pv = (p) => (p && { width: p.width, height: p.height, animated: safeMedia(p.animated), still: safeMedia(p.still) }) || null;
+ return {
+ id: String(g.id || g.url), url: g.url, width: g.width, height: g.height,
+ title: typeof g.title === 'string' ? g.title : '', bytes: g.bytes || null,
+ format: g.format === 'webp' ? 'webp' : 'gif',
+ lqip: typeof g.lqip === 'string' && /^data:image\/[a-z]+;base64,[A-Za-z0-9+/=]+$/.test(g.lqip) ? g.lqip : null,
+ mp4: safeMedia(g.mp4),
+ w240: pv(g.previews && g.previews.w240), w480: pv(g.previews && g.previews.w480),
+ };
+}
+// NIP-92 imeta for a picked GIF, so other clients needn't sniff the URL.
+export const gifMedia = (g) => ({
+ url: g.url, m: g.format === 'webp' ? 'image/webp' : 'image/gif',
+ dim: g.width + 'x' + g.height, alt: g.title || 'GIF', size: g.bytes || undefined,
+});
+
+const answers = new Map(); // normalised query -> Promise<{ items } | { error }>
+export function searchGifs(q, fetchImpl = (u) => fetch(u)) {
+ const key = normGifQuery(q);
+ if (answers.has(key)) return answers.get(key);
+ const p = (async () => {
+ let res;
+ try {
+ res = await Promise.race([
+ fetchImpl(GIF_API + '/search?' + new URLSearchParams({ q: key, limit: String(LIMIT) })),
+ new Promise((_, no) => setTimeout(() => no(new Error('timeout')), 15000)),
+ ]);
+ } catch { return { error: 'down' }; } // unregistered origins and 503s arrive as network errors
+ if (res.status === 429) return { error: 'limited' };
+ if (!res.ok) return { error: 'down' };
+ try {
+ const body = await res.json();
+ return { items: (body.items || []).map(cleanGif).filter(Boolean) };
+ } catch { return { error: 'down' }; }
+ })();
+ answers.set(key, p);
+ // a failure is not an answer to keep: the next try asks again (a rate
+ // limit after a minute)
+ p.then((r) => { if (r.error) setTimeout(() => { if (answers.get(key) === p) answers.delete(key); }, r.error === 'limited' ? 60_000 : 5_000); });
+ return p;
+}
+
+// The picker: a sheet with a search field, starter chips, a grid of tiles
+// and the attribution. `ui.gifPick` = { for, q, shown, status, items }; the
+// caller draws sheet() where its composer is and handles the pick.
+export function createGifPicker({ h, t, ui, render }) {
+ let timer = 0, seq = 0;
+ const reduce = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const wide = () => typeof window !== 'undefined' && window.innerWidth >= 640;
+
+ function run(q) {
+ const st = ui.gifPick;
+ if (!st) return;
+ const key = normGifQuery(q);
+ if (!gifQueryOk(key)) return;
+ const my = ++seq;
+ st.shown = key; st.status = 'loading';
+ render();
+ searchGifs(key).then((r) => {
+ if (my !== seq || ui.gifPick !== st) return; // a later query owns the grid
+ if (r.error) { st.status = r.error; st.items = []; }
+ else { st.status = 'ready'; st.items = r.items; }
+ render();
+ });
+ }
+ function open(forWhat) {
+ ui.gifPick = { for: forWhat, q: '', shown: '', status: 'idle', items: [] };
+ run(GIF_STARTERS[0]);
+ }
+ function close() { ui.gifPick = null; clearTimeout(timer); render(); }
+
+ // One observer for every tile: a clip is attached only near the screen
+ // and plays only on it; far away it lets go of its media.
+ let io = null;
+ const watch = (tile) => {
+ if (typeof IntersectionObserver === 'undefined') return;
+ io ||= new IntersectionObserver((entries) => {
+ for (const en of entries) {
+ const v = en.target.querySelector('video');
+ if (!v) continue;
+ if (en.isIntersecting) {
+ if (!v.getAttribute('src')) v.setAttribute('src', v.dataset.src + '#t=0.001');
+ v.play().catch(() => {});
+ } else if (v.getAttribute('src')) {
+ v.pause();
+ if (!en.target.isConnected) io.unobserve(en.target);
+ }
+ }
+ }, { rootMargin: '200px 0px' });
+ io.observe(tile);
+ };
+
+ function tile(g, onPick) {
+ const pv = (wide() ? g.w480 : g.w240) || g.w240 || g.w480;
+ const ratio = Math.min(2, Math.max(0.75, g.width / g.height));
+ const still = pv && pv.still;
+ const moving = !reduce();
+ const fallback = (moving && pv && pv.animated) || still || null;
+ const media = moving && g.mp4
+ ? h('video', { muted: true, loop: true, playsinline: true, preload: 'none', 'data-src': g.mp4,
+ onError: (e) => { // no copy after all: the animated preview
+ const box = e.target.parentElement;
+ if (box && fallback) e.target.replaceWith(h('img', { src: fallback, alt: '', loading: 'lazy' }));
+ } })
+ : fallback ? h('img', { src: fallback, alt: '', loading: 'lazy', onLoad: (e) => { e.target.parentElement.style.backgroundImage = 'none'; } }) : null;
+ if (media && media.tagName === 'VIDEO') {
+ media.muted = true;
+ media.addEventListener('playing', () => { media.parentElement && (media.parentElement.style.backgroundImage = 'none'); }, { once: true });
+ }
+ const el = h('button', {
+ type: 'button', class: 'gif-tile', title: g.title || 'GIF', 'aria-label': g.title || 'GIF', 'data-key': 'gif:' + g.id,
+ style: 'aspect-ratio:' + ratio.toFixed(3) + (g.lqip ? ';background-image:url("' + g.lqip + '")' : ''),
+ onClick: () => { const p = ui.gifPick; close(); onPick(g, p); },
+ }, media);
+ el._skipMorph = true; // its clip is attached and played by the observer, not by renders
+ if (media && media.tagName === 'VIDEO') watch(el);
+ return el;
+ }
+
+ function sheet(forWhat, onPick) {
+ const st = ui.gifPick;
+ if (!st || st.for !== forWhat) return null;
+ const body = st.status === 'ready'
+ ? (st.items.length
+ ? h('div', { class: 'gif-grid', 'data-key': 'gifs:' + st.shown }, ...st.items.map((g) => tile(g, onPick)))
+ : h('div', { class: 'small muted gif-note' }, t('gifNone')))
+ : st.status === 'loading' ? h('div', { class: 'gif-note' }, h('span', { class: 'spinner' }))
+ : st.status === 'limited' ? h('div', { class: 'small muted gif-note' }, t('gifLimited'))
+ : st.status === 'down' ? h('div', { class: 'small muted gif-note' }, t('gifDown'))
+ : null;
+ return h('div', { class: 'confirm-pop-backdrop gif-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col gif-sheet' },
+ h('div', { class: 'row between gif-head' },
+ h('span', { class: 'small muted', html: t('gifFrom') + ' <a href="https://nostr.build" target="_blank" rel="noopener noreferrer">nostr.build</a>' }),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', onClick: close }, t('cancel'))),
+ h('input', {
+ type: 'search', class: 'gif-search', placeholder: t('gifSearchPh'), value: st.q, enterkeyhint: 'search',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ onInput: (e) => {
+ st.q = e.target.value;
+ clearTimeout(timer);
+ timer = setTimeout(() => run(st.q.trim() || GIF_STARTERS[0]), 250);
+ },
+ onKeydown: (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(timer); run(st.q.trim() || GIF_STARTERS[0]); } },
+ }),
+ h('div', { class: 'row gif-chips' },
+ ...GIF_STARTERS.map((s) => h('button', {
+ type: 'button', class: 'feed-chip' + (st.shown === s ? ' on' : ''),
+ onClick: () => { st.q = s; const f = document.querySelector('.gif-search'); if (f) f.value = s; run(s); },
+ }, s))),
+ h('div', { class: 'gif-body' }, body)));
+ }
+
+ function button(forWhat, cls = 'gif-btn') {
+ return h('button', {
+ type: 'button', class: cls, title: t('gifBtnTitle'), 'aria-label': t('gifBtnTitle'),
+ onClick: (e) => { e.preventDefault(); open(forWhat); },
+ }, 'GIF');
+ }
+
+ return { open, close, sheet, button };
+}
diff --git a/src/i18n.js b/src/i18n.js
index e80e2f8..5bf7f91 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -517,6 +517,12 @@ const STR = {
msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.",
msgAmberDms: 'Signed in with Amber, messages sent to you can’t be opened here, only ones you sent from this device.',
msgAmberConnect: 'Connect Amber',
+ gifBtnTitle: 'Add a GIF',
+ gifSearchPh: 'Search GIFs',
+ gifFrom: 'GIFs from',
+ gifNone: 'No GIFs found.',
+ gifDown: 'GIF search isn’t available right now.',
+ gifLimited: 'Too many searches. Try again in a minute.',
msgReconnect: 'Reconnect',
msgDmsTitle: 'Messages',
msgCommunitiesTitle: 'Communities',
diff --git a/src/style.css b/src/style.css
index dad0520..2ed97a1 100644
--- a/src/style.css
+++ b/src/style.css
@@ -236,7 +236,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
/* ---- inputs ---- */
label.field { display: block; }
.field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; }
-input[type=text], input[type=password], input[type=number], textarea, select {
+input[type=text], input[type=search], input[type=password], input[type=number], textarea, select {
width: 100%; font: inherit; color: var(--ink); background: var(--paper);
border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
}
@@ -1241,7 +1241,7 @@ button, .btn { transition: filter .12s, background .15s, border-color .15s, box-
.chat-avatar.clickable:hover { transform: scale(1.07); }
.header-avatar .chat-avatar { transition: transform .15s ease; }
.header-avatar:hover .chat-avatar { transform: scale(1.08); }
-input[type=text], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; }
+input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; }
.settings-nav::after { transition: transform .18s ease, color .18s; }
.settings-nav:hover::after { transform: translateX(3px); color: var(--ink); }
@@ -1632,3 +1632,22 @@ body::after {
.mention-ac .item { cursor: pointer; padding: 6px 10px; }
/* `code` in a post */
.md-code { font-family: var(--mono, ui-monospace, monospace); font-size: .9em; background: color-mix(in srgb, currentColor 9%, transparent); border-radius: 4px; padding: 0 .3em; }
+
+/* GIF picker (gifs.nostr.build) */
+.gif-btn { font-weight: 700; font-size: 12px; letter-spacing: .04em; }
+.chat-gif { font-weight: 700; font-size: 11px; letter-spacing: .04em; padding: 0 8px; }
+.gif-backdrop { align-items: flex-end; padding: 0; }
+.gif-sheet { width: 100%; max-width: 560px; height: min(78vh, 640px); gap: 8px; border-radius: 16px 16px 0 0; padding: 12px; }
+@media (min-width: 640px) { .gif-backdrop { align-items: center; padding: 24px; } .gif-sheet { border-radius: 16px; } }
+.gif-head { align-items: center; }
+.gif-head a { color: inherit; text-decoration: underline; }
+.gif-search { width: 100%; }
+.gif-chips { gap: 6px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; flex-shrink: 0; }
+.gif-chips::-webkit-scrollbar { display: none; }
+.gif-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
+.gif-grid { columns: 2; column-gap: 6px; }
+@media (min-width: 640px) { .gif-grid { columns: 3; } }
+.gif-tile { padding: 0; border: 0; border-radius: 8px; overflow: hidden; background: #000 center / cover no-repeat; width: 100%; cursor: pointer; display: block; margin: 0 0 6px; break-inside: avoid; }
+.gif-search::-webkit-search-cancel-button { cursor: pointer; }
+.gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
+.gif-note { display: flex; justify-content: center; padding: 32px 0; }