source: Klonkt/src/assets/js/guardian.js@ d9ad6c5

main
Last change on this file since d9ad6c5 was d9ad6c5, checked in by Robin Genis <roboburr@โ€ฆ>, 6 weeks ago

Een hulpvraag hoort in Berichten, niet in de Krant

Een ๐Ÿ›Ÿ van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er รฉรฉn partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, รฉรฉn kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een ๐Ÿ›Ÿ-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een ๐Ÿ›Ÿ blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • รฉรฉn renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mรฉt quote en capture in
Berichten en de PWA. Nog niet uitgerold.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@โ€ฆ>

  • Property mode set to 100644
File size: 16.4 KB
RevLineย 
[c26cc18]1/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
2 manages the guardian push channel. No framework, no inline scripts (CSP).
3 All user-facing text comes from state.strings (server i18n). */
[318d0c2]4(function () {
5 'use strict';
[fcd6964]6 // A crash here used to fail silently (buttons just do nothing). Surface it on
7 // the page AND the console so the cause is visible instead of "everything hangs".
8 function fatal(msg) {
9 try {
10 var b = document.getElementById('g-fatal') || document.createElement('div');
11 b.id = 'g-fatal'; b.className = 'g-msg err';
12 b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
13 b.textContent = 'Guardian: ' + msg;
14 var root = document.querySelector('main') || document.body;
15 if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
16 } catch (e) { /* last resort */ }
17 try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
18 }
19 try {
[c26cc18]20 var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
21 var T = S.strings || {};
[318d0c2]22
23 function el(tag, cls, text) {
24 var n = document.createElement(tag);
25 if (cls) n.className = cls;
26 if (text != null) n.textContent = text;
27 return n;
28 }
29 function handleOf(uri, cached) {
[fcd6964]30 if (cached && cached.charAt(0) === '@') return cached; // trust only real @handles
[318d0c2]31 try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
32 catch (e) { return uri; }
33 }
[c26cc18]34 function when(s) { return String(s || '').slice(0, 16).replace('T', ' '); }
35 function show(id, on) { document.getElementById(id).hidden = !on; }
[318d0c2]36
[c26cc18]37 // โ”€โ”€ 1. Help requests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[318d0c2]38 function renderHelp() {
39 var list = document.getElementById('help-list');
40 list.textContent = '';
[c26cc18]41 var help = S.help || [];
42 help.forEach(function (h) {
[318d0c2]43 var card = el('div', 'g-card help');
44 var row = el('div', 'row');
[d9ad6c5]45 var who = el('span', 'who grow');
46 // name_html carries the custom emojis (FEP-9098) of the display name, the
47 // same way de Krant renders a byline. Falls back to the plain name.
48 if (h.name_html) who.innerHTML = h.name_html;
49 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
50 row.appendChild(who);
[c26cc18]51 row.appendChild(el('span', 'when', when(h.published || h.created_at)));
[318d0c2]52 card.appendChild(row);
[d9ad6c5]53 var body = el('div', 'body g-note');
54 // body_html is the shared note-body partial, rendered server-side: the
55 // content with its emojis, the quote / link-preview card and the media.
56 // Falls back to the bare content for rows stored before that existed.
57 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest
[318d0c2]58 card.appendChild(body);
59 if (h.note_url) {
[c26cc18]60 var a = el('a', 'g-link', T.open || 'open');
61 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
62 card.appendChild(a);
[318d0c2]63 }
64 list.appendChild(card);
65 });
[c26cc18]66 var badge = document.getElementById('help-count');
67 badge.textContent = help.length; badge.hidden = help.length === 0;
68 show('help-empty', help.length === 0);
[318d0c2]69 }
70
[780a7c6]71 // โ”€โ”€ 3. Offers I am a party to (sent, or a co-guardianship to co-approve) โ”€
72 function answer(offerId, decision, btn) {
73 if (btn) btn.disabled = true;
74 fetch('/guardian/offer', {
75 method: 'POST', headers: { 'Content-Type': 'application/json' },
76 body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
77 }).then(refresh);
78 }
79 function offerCard(o) {
80 var card = el('div', 'g-card');
81 var row = el('div', 'row');
82 var subject = o['shaer:iAmCandidate']
83 ? handleOf(o['shaer:ward'], o['shaer:wardHandle']) // my sent offer: about the ward
84 : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
85 row.appendChild(el('span', 'who grow', subject));
86 if (o['shaer:iAmCandidate']) {
87 // My own offer, waiting for the others to accept.
88 row.appendChild(el('span', 'tag wait', T.pending));
89 var rt = el('button', 'quiet small', T.retract);
90 rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
91 row.appendChild(rt);
92 } else if (o['shaer:needsMyAccept']) {
93 // A co-guardianship offer for a ward I already guard: my call.
94 row.appendChild(el('span', 'tag co', T.coguard));
95 var ac = el('button', 'small', T.accept);
96 ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
97 var rj = el('button', 'quiet small', T.reject);
98 rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
99 row.appendChild(ac); row.appendChild(rj);
100 } else {
101 row.appendChild(el('span', 'tag wait', T.awaiting_others));
102 }
103 card.appendChild(row);
104 return card;
105 }
[c26cc18]106 function renderPending() {
107 var list = document.getElementById('pending-list');
108 list.textContent = '';
[780a7c6]109 var offers = S.offers || [];
110 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
111 show('pending-section', offers.length > 0);
[318d0c2]112 }
[c26cc18]113
114 // โ”€โ”€ 4. Accepted wards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[318d0c2]115 function renderWards() {
[c26cc18]116 var list = document.getElementById('wards-list');
117 list.textContent = '';
118 var wards = S.wards || [];
119 wards.forEach(function (w) {
120 var card = el('div', 'g-card');
121 var row = el('div', 'row');
122 row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
123 row.appendChild(el('span', 'tag ok', T.active));
[f1c50f9]124 var wave = el('button', 'small', T.wave || '๐Ÿ‘‹ Wave');
125 wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
126 row.appendChild(wave);
[2a76184]127 // Gated feature: external (non-fediverse) embeds. Off by default for a
128 // ward; only a guardian can open it, and the gate is enforced server-side
129 // when the feed is built, so this button is the only thing that moves it.
[65abc85]130 // Shown for EVERY ward, including one on another server. There the value
131 // is unknown (it lives on the ward's server), but proposing is exactly as
132 // possible: the proposal travels, the ward's server tallies the guardians
133 // and enforces. A guardian next door must not have more say than one far
134 // away.
135 var known = w.embeds === true || w.embeds === false;
136 var emb = el('button', 'quiet small',
137 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
138 emb.addEventListener('click', function () {
139 emb.disabled = true;
140 fetch('/guardian/wards/embeds', {
141 method: 'POST', headers: { 'Content-Type': 'application/json' },
142 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
143 }).then(function (r) { return r.json(); })
144 .then(function (j) {
145 // Not settled yet: the other guardians still have to answer.
146 if (j && j.state === 'open') {
147 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
148 emb.disabled = true;
149 return;
150 }
151 refresh();
152 })
153 .catch(function () { emb.disabled = false; });
154 });
155 row.appendChild(emb);
[c26cc18]156 var btn = el('button', 'quiet small', T.release);
[c628dcd4]157 // Releasing a ward is heavy and hard to undo (coming back needs a fresh
158 // offer the ward accepts), so it asks first and spells out what changes.
159 btn.addEventListener('click', function () {
160 var who = handleOf(w.other_uri, w.other_handle);
161 var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
162 if (window.confirm(msg)) remove(w.other_uri, btn);
163 });
[c26cc18]164 row.appendChild(btn);
165 card.appendChild(row);
166 list.appendChild(card);
167 });
168 show('wards-empty', wards.length === 0);
169 }
170
[f1c50f9]171 function sendWave(uri, btn) {
172 btn.disabled = true;
173 fetch('/guardian/api/wave', {
174 method: 'POST', headers: { 'Content-Type': 'application/json' },
175 body: JSON.stringify({ ward: uri, site: S.site }),
176 }).then(function (r) { return r.json(); })
177 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '๐Ÿ‘‹ sent') : (T.wave || '๐Ÿ‘‹ Wave'); })
178 .catch(function () { btn.disabled = false; });
179 }
180
[c26cc18]181 function remove(uri, btn) {
182 btn.disabled = true;
183 fetch('/guardian/wards/remove', {
184 method: 'POST', headers: { 'Content-Type': 'application/json' },
185 body: JSON.stringify({ uri: uri, site: S.site }),
186 }).then(refresh);
[318d0c2]187 }
188
[c26cc18]189 function renderAll() { renderHelp(); renderPending(); renderWards(); }
190
[f1c50f9]191 // โ”€โ”€ 0. Wards' corner: read-only feed of your wards' posts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
192 function renderFeed(items) {
193 var list = document.getElementById('feed-list');
194 list.textContent = '';
195 (items || []).forEach(function (p) {
196 var card = el('div', 'g-card feed');
197 var head = el('div', 'row');
198 head.appendChild(el('span', 'who grow', p.author));
199 if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
200 card.appendChild(head);
201 var body = el('div', 'feed-body');
202 if (p.cw) {
203 var d = document.createElement('details');
204 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
205 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
206 body.appendChild(d);
207 } else {
208 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
209 }
210 card.appendChild(body);
211 list.appendChild(card);
212 });
213 show('feed-section', (items || []).length > 0);
214 }
215
216 function loadFeed() {
217 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
218 .then(function (r) { return r.json(); })
219 .then(function (f) { if (f && !f.error) renderFeed(f.items); })
220 .catch(function () { /* corner just stays hidden */ });
221 }
222
223 // โ”€โ”€ 0b. Follow requests on your wards (ยง5.3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
224 function answerFollow(id, decision, btn) {
225 if (btn) btn.disabled = true;
226 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
227 method: 'POST', headers: { 'Content-Type': 'application/json' },
228 body: JSON.stringify({ decision: decision, site: S.site }),
229 }).then(loadFollowReqs);
230 }
231 function renderFollowReqs(items) {
232 var list = document.getElementById('follow-list');
233 list.textContent = '';
234 (items || []).forEach(function (f) {
235 var card = el('div', 'g-card');
236 var row = el('div', 'row');
237 row.appendChild(el('span', 'who grow', f.follower + ' โ†’ ' + f.ward));
238 var ok = el('button', 'small', T.accept || 'Accept');
239 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
240 var no = el('button', 'quiet small', T.reject || 'Deny');
241 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
242 row.appendChild(ok); row.appendChild(no);
243 card.appendChild(row);
244 list.appendChild(card);
245 });
246 show('follow-section', (items || []).length > 0);
247 }
248 function loadFollowReqs() {
249 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
250 .then(function (r) { return r.json(); })
251 .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
252 .catch(function () { /* stays hidden */ });
253 }
254
[318d0c2]255 function refresh() {
[c26cc18]256 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
[318d0c2]257 .then(function (r) { return r.json(); })
[f1c50f9]258 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
259 .then(loadFeed).then(loadFollowReqs);
[318d0c2]260 }
261
[c26cc18]262 // โ”€โ”€ 2. Adopt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
263 var form = document.getElementById('adopt-form');
264 var input = document.getElementById('adopt-handle');
265 var adoptBtn = document.getElementById('adopt-btn');
266 var msg = document.getElementById('adopt-msg');
267 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
268
269 form.addEventListener('submit', function (ev) {
[318d0c2]270 ev.preventDefault();
271 var handle = input.value.trim();
272 if (!handle) return;
[c26cc18]273 adoptBtn.disabled = true;
274 setMsg(T.sending || 'โ€ฆ', false);
[318d0c2]275 fetch('/guardian/adopt', {
276 method: 'POST', headers: { 'Content-Type': 'application/json' },
[c26cc18]277 body: JSON.stringify({ handle: handle, site: S.site }),
[318d0c2]278 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
279 .then(function (res) {
[c26cc18]280 adoptBtn.disabled = false;
281 if (res.ok) {
282 input.value = '';
283 // Always refresh: the offer is recorded even if delivery is still
284 // in flight. Show it under "Verzonden aanvragen".
285 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
286 refresh();
287 } else {
288 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
289 }
[318d0c2]290 })
[c26cc18]291 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
[318d0c2]292 });
293
294 // โ”€โ”€ Site picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
295 var picker = document.getElementById('site-picker');
296 if (picker) picker.addEventListener('change', function () {
297 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
298 });
299
[c26cc18]300 // โ”€โ”€ 5. Push โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[318d0c2]301 var toggle = document.getElementById('push-toggle');
302 var pmsg = document.getElementById('push-msg');
303 function pushState() {
304 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
305 navigator.serviceWorker.register('/sw.js').catch(function () {});
306 navigator.serviceWorker.ready
307 .then(function (reg) { return reg.pushManager.getSubscription(); })
308 .then(function (sub) {
309 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
310 toggle.dataset.subscribed = sub ? '1' : '';
[c26cc18]311 toggle.classList.toggle('is-on', !!sub);
[318d0c2]312 });
313 }
314 function urlB64(base64) {
315 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
316 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
317 var raw = atob(b); var arr = new Uint8Array(raw.length);
318 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
319 return arr;
320 }
321 toggle.addEventListener('click', function () {
322 pmsg.hidden = true;
323 navigator.serviceWorker.ready.then(function (reg) {
324 if (toggle.dataset.subscribed) {
325 reg.pushManager.getSubscription().then(function (sub) {
326 if (!sub) return;
327 fetch('/push/unsubscribe', {
328 method: 'POST', headers: { 'Content-Type': 'application/json' },
329 body: JSON.stringify({ endpoint: sub.endpoint }),
330 }).then(function () { return sub.unsubscribe(); }).then(pushState);
331 });
332 return;
333 }
334 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
335 if (!v.publicKey) throw new Error('no key');
336 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
337 }).then(function (sub) {
338 return fetch('/push/subscribe', {
339 method: 'POST', headers: { 'Content-Type': 'application/json' },
340 body: JSON.stringify({
341 subscription: sub.toJSON(),
342 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
343 uaLabel: 'Guardian PWA',
344 }),
345 });
346 }).then(pushState).catch(function (e) {
[c26cc18]347 pmsg.hidden = false; pmsg.className = 'g-msg err';
348 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
[318d0c2]349 });
350 });
351 });
352
[f1c50f9]353 renderAll(); pushState(); loadFeed(); loadFollowReqs();
[c26cc18]354 setInterval(refresh, 45000); // live-ish while open
[fcd6964]355 } catch (e) {
356 fatal((e && e.message) || String(e));
357 }
[318d0c2]358})();
Note: See TracBrowser for help on using the repository browser.