1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
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 };
}
|