/* * The feed reader (the server side is the feeds app, /feeds/…): the "Feeds" tab * with two parts that work differently on purpose — * Nachrichten: stories (the same event from several sources is one card), * the "Seit letztem Mal" briefing on top, old news simply goes away; * Blogs: a plain list of unread articles, per blog if wanted. * Articles open in the page (full text from the server), the browser only on request. * Runs in the web page, the desktop app and both Android apps (in diora Reader, * which has no tab bar, it brings a small Bücher/Feeds bar). Chrome-61-safe: * the e-reader app transpiles it (compat61), so no ?. / ?? here. */ (function () { 'use strict'; const tab = document.getElementById('tab-feeds'); if (!tab) return; const READER_APP = document.documentElement.classList.contains('nui-reader-app'); const st = { view: 'news', // news | blogs | manage state: null, // /feeds/state/ briefing: null, stories: [], storiesMore: false, category: '', items: [], itemsMore: false, blogSource: '', blogUnread: true, blogFav: false, // main feed: only the favorite blogs rareFirst: true, // within a day: blogs that seldom post first blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name found: null, // discover results opml: null, // parsed OPML import editing: null, // source id being edited loading: false, error: '', }; try { const saved = localStorage.getItem('diora_feeds_view'); if (saved === 'news' || saved === 'blogs') st.view = saved; if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false; if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false; } catch (e) {} const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); async function api(path, body) { const opts = {credentials: 'same-origin', headers: {}}; if (body !== undefined) { opts.method = 'POST'; opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); let data = null; try { data = await r.json(); } catch (e) {} if (!r.ok) throw new Error((data && data.error) || ('Fehler ' + r.status)); return data; } // ---- time function when(iso) { if (!iso) return ''; const d = new Date(iso); const mins = Math.round((Date.now() - d.getTime()) / 60000); if (mins < 1) return 'gerade eben'; if (mins < 60) return 'vor ' + mins + ' Min.'; if (mins < 6 * 60) return 'vor ' + Math.round(mins / 60) + ' Std.'; const hm = ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2); const day = dayLabel(d); return (day === 'Heute' ? '' : day + ', ') + hm; } function dayLabel(d) { const today = new Date(); today.setHours(0, 0, 0, 0); const that = new Date(d.getTime()); that.setHours(0, 0, 0, 0); const diff = Math.round((today - that) / 86400000); if (diff === 0) return 'Heute'; if (diff === 1) return 'Gestern'; return d.toLocaleDateString('de-DE', {weekday: 'short', day: 'numeric', month: 'short'}); } // ---- loading async function loadState() { st.state = await api('/feeds/state/'); } async function loadNews(more) { const q = []; if (more && st.stories.length) q.push('before=' + encodeURIComponent(st.stories[st.stories.length - 1].last_at)); if (st.category) q.push('category=' + encodeURIComponent(st.category)); const [news, briefing] = await Promise.all([ api('/feeds/news/' + (q.length ? '?' + q.join('&') : '')), more ? Promise.resolve(st.briefing) : api('/feeds/briefing/'), ]); st.stories = more ? st.stories.concat(news.stories) : news.stories; st.storiesMore = news.more; st.briefing = briefing; } async function loadBlogs(more) { const q = []; if (st.blogSource) q.push('source=' + st.blogSource); else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder)); if (st.blogUnread) q.push('unread=1'); if (st.blogFav && !st.blogSource && st.blogFolder === null) q.push('fav=1'); if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published)); const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : '')); st.items = more ? st.items.concat(data.items) : data.items; st.itemsMore = data.more; } async function load(more) { st.loading = true; st.error = ''; render(); try { if (!more) await loadState(); if (st.view === 'news') await loadNews(more); else if (st.view === 'blogs') await loadBlogs(more); } catch (e) { st.error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message; } st.loading = false; render(); } // ---- rendering function sources(kind) { return ((st.state && st.state.sources) || []).filter(s => !kind || s.kind === kind); } function render() { const s = st.state; const newsN = s ? s.news_new : 0; const blogN = s ? s.blogs_new : 0; // new = younger than a day, unread (hidden blogs not counted) let body; if (st.view === 'manage') body = renderManage(); else if (!s && st.loading) body = '

Lädt …

'; else if (st.view === 'news') body = renderNews(); else body = renderBlogs(); tab.innerHTML = '
' + '
' + (st.view === 'manage' ? '

Feeds verwalten

' : '
' + '' + '' + '
' + '
' + '' + '' + '
') + '
' + (st.error ? '

' + esc(st.error) + '

' : '') + body + '
'; } function storyMeta(x) { const lead = x.lead || {}; const others = x.sources - 1; return esc(lead.source_title) + (others > 0 ? ' +' + others + '' : '') + ' · ' + esc(when(x.last_at)); } function storyRow(x) { const lead = x.lead || {}; return ''; } function renderBriefing() { const b = st.briefing; if (!b || !b.total) return ''; let h = '
' + '

Seit letztem Mal

' + '' + b.total + (b.total === 1 ? ' Meldung' : ' Meldungen') + ' seit ' + esc(when(b.since)) + '
'; if (b.top.length) { h += '

Top-Themen

' + b.top.map(storyRow).join(''); } b.sections.forEach(sec => { h += '

' + esc(sec.category) + ' ' + sec.count + '

' + sec.stories.map(storyRow).join(''); if (sec.count > sec.stories.length) { h += ''; } }); h += '
'; return h; } function renderNews() { if (!sources('news').length) { return '

Noch keine Nachrichtenquellen.

' + '
'; } const cats = (st.state.categories || []); let h = renderBriefing(); if (cats.length > 1) { h += '
' + '' + cats.map(c => '').join('') + '
'; } if (!st.stories.length) return h + '

' + (st.loading ? 'Lädt …' : 'Gerade keine Nachrichten.') + '

'; let day = ''; st.stories.forEach(x => { const d = dayLabel(new Date(x.last_at)); if (d !== day) { day = d; h += '

' + esc(d) + '

'; } const lead = x.lead || {}; h += ''; }); if (st.storiesMore) h += ''; return h; } // ---- blogs: days, favorites first, rare blogs first (after Kino's feed) const NEW_MS = 24 * 3600000; const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section function inFolderOf(b, f) { const list = b.folders || []; if (f === '') return !list.length; const k = f.toLowerCase(); return list.some(x => x.toLowerCase() === k); } function isNew(i, src) { return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden); } function readingTime(i) { return i.words >= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : ''; } function section(iso) { const d = new Date(iso); const label = dayLabel(d); if (label === 'Heute' || label === 'Gestern') return label; const days = (Date.now() - d.getTime()) / 86400000; if (days < 7) return 'Diese Woche'; if (days < 31) return 'Diesen Monat'; return 'Älter'; } // Posts into sections (newest first); in each, up to FAV_PER_DAY posts per // favorite blog first, then the rest — blogs that post seldom ahead of the // busy ones ("Seltene zuerst"), or simply by time. function blogSections(items, byId) { const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter']; const groups = {}; items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); }); const newest = (a, b) => new Date(b.published) - new Date(a.published); return order.filter(k => groups[k]).map(k => { const list = groups[k].slice().sort(newest); const favCount = {}; const fav = []; const rest = []; list.forEach(i => { const src = byId[i.source]; if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) { favCount[i.source] = (favCount[i.source] || 0) + 1; fav.push(i); } else { rest.push(i); } }); if (st.rareFirst) { const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; }; rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b)); } return {label: k, items: fav.concat(rest)}; }); } function renderBlogs() { const blogs = sources('blog'); if (!blogs.length) { return '

Noch keine Blogs abonniert.

' + '
'; } const byId = {}; blogs.forEach(b => { byId[b.id] = b; }); // Folders (a blog's category): chips with their unread count, a dot when something is new. const folders = (st.state.folders || []); const inFolder = f => blogs.filter(b => inFolderOf(b, f)); const sum = (list, key) => list.reduce((n, b) => n + b[key], 0); const visible = blogs.filter(b => !b.hidden); let h = ''; if (folders.length) { const chip = (value, label, list) => ''; h += '
' + chip(null, 'Alle', visible) + folders.map(f => chip(f, f, inFolder(f))).join('') + (blogs.some(b => !(b.folders || []).length) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '
'; } const main = st.blogFolder === null; const shown = main ? blogs : inFolder(st.blogFolder); h += '
' + '' + '' + (main ? '' : '') + '' + '
'; h += '
' + '' + (st.items.some(i => !i.read) ? '' : '') + '
'; const one = st.blogSource ? byId[st.blogSource] : null; if (one) { // The selected blog's switches. h += '
' + '' + '' + '' + '
'; } if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; const reading = st.items.filter(i => !i.read && i.progress >= 0.05 && i.progress < 0.9) .sort((a, b) => b.progress - a.progress).slice(0, 3); if (reading.length) { h += '

Weiterlesen

' + reading.map(i => '').join(''); } blogSections(st.items, byId).forEach(sec => { h += '

' + esc(sec.label) + '

'; h += sec.items.map(i => { const src = byId[i.source]; const rt = readingTime(i); return ''; }).join(''); }); if (st.itemsMore) h += ''; return h; } function kindSelect(name, value) { return ''; } function categoryInput(value) { const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []); return '' + '' + cats.map(c => ''; } function renderManage() { const s = st.state || {sources: [], settings: {}}; let h = '

Feed hinzufügen

' + '
' + '
'; if (st.found) { h += st.found.map((f, n) => '
' + '
' + esc(f.title) + '
' + esc(f.url) + '
' + (f.samples.length ? '' : '') + (f.subscribed ? '

Schon abonniert.

' : '
' + '
' + '') + '
').join(''); } h += '
'; const news = s.sources.filter(x => x.kind === 'news'); const blogs = s.sources.filter(x => x.kind === 'blog'); const group = (title, list) => !list.length ? '' : '

' + title + ' ' + list.length + '

' + list.map(x => x.id === st.editing ? editForm(x) : '').join('') + '
'; h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title))); const firstFolder = b => ((b.folders || [])[0] || '~').toLowerCase(); h += group('Blogs', blogs.sort((a, b) => firstFolder(a).localeCompare(firstFolder(b)) || a.title.localeCompare(b.title))); // The folders themselves: rename, dissolve (the blogs stay). const folders = (s.folders || []); if (folders.length) { h += '

Ordner ' + folders.length + '

' + folders.map(f => '
' + esc(f) + ' ' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs' + '' + '
').join('') + '

Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.

'; } h += '

Import & Einstellungen

' + ' ' + (window.DioraNative ? '' : 'OPML exportieren'); if (st.opml) h += renderOpml(); const keep = (s.settings && s.settings.news_keep_days) || 2; h += '

Ältere Nachrichten verschwinden von selbst (gemerkte bleiben). Blogartikel bleiben, bis sie gelesen sind.

'; return h; } function editForm(x) { return '
' + '' + '
' + esc(x.url) + '
' + (x.last_error ? '

Letzter Fehler: ' + esc(x.last_error) + '

' : '') + '
' + (x.kind === 'news' ? '' : folderPicker(x)) + (x.kind === 'blog' ? '' + '' + '' : '') + '
' + '
' + '' + '
' + loginForm(x); } function folderPicker(x) { const all = (st.state && st.state.folders) || []; return '
Ordner' + all.map(f => '').join('') + '
'; } // A paywalled site (junge Welt …): the subscriber login, kept encrypted on the // server, which then fetches whole articles. The password never comes back. function loginForm(x) { const l = x.login; if (!l) return ''; let h = '
' + '

Abo-Anmeldung bei ' + esc(l.site) + '

'; if (l.username) { h += '

' + (l.ok ? '✓ Angemeldet als ' : '⚠ Anmeldung klappt nicht: ') + esc(l.ok ? l.username : (l.error || l.username)) + '

' + '
'; if (l.ok) return h + '
'; h += '

Neu eingeben:

'; } return h + '' + '' + '

Das Passwort liegt verschlüsselt auf dem diora-Server und wird nie wieder angezeigt. ' + 'Damit holt der Server die ganzen Artikel statt nur des freien Teils.

' + '
'; } function renderOpml() { const groups = st.opml; const n = groups.reduce((a, g) => a + (g.kind === 'skip' ? 0 : g.feeds.length), 0); return '

Was ist was? Pro Ordner:

' + groups.map((g, i) => '
' + esc(g.name || 'Ohne Ordner') + ' (' + g.feeds.length + ')' + '
').join('') + ' ' + '
'; } // ---- OPML function parseOpml(text) { const doc = new DOMParser().parseFromString(text, 'text/xml'); const groups = {}; const order = []; Array.prototype.forEach.call(doc.querySelectorAll('outline[xmlUrl], outline[xmlurl]'), o => { const url = o.getAttribute('xmlUrl') || o.getAttribute('xmlurl'); const parent = o.parentNode && o.parentNode.nodeName.toLowerCase() === 'outline' ? o.parentNode : null; const name = parent ? (parent.getAttribute('text') || parent.getAttribute('title') || '') : ''; if (!groups[name]) { groups[name] = {name, feeds: [], kind: /nachricht|news|politik|zeitung|presse|aktuell|welt|wirtschaft|tech/i.test(name) ? 'news' : 'blog'}; order.push(name); } groups[name].feeds.push({url, title: o.getAttribute('title') || o.getAttribute('text') || url}); }); return order.map(n => groups[n]); } // ---- article view let reader = null; // the overlay element let readerList = []; // what "next" walks through (blog items) let readerStory = null; let readerItem = null; function fontSize() { let v = 18; try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {} return Math.max(14, Math.min(26, v)); } function ensureReader() { if (reader) return reader; reader = document.createElement('div'); reader.id = 'fd-article'; reader.hidden = true; reader.addEventListener('click', onReaderClick); reader.addEventListener('scroll', () => { clearTimeout(progressTimer); progressTimer = setTimeout(saveProgress, 1200); }, {passive: true}); document.body.appendChild(reader); return reader; } function readerOpen() { return !!(reader && !reader.hidden); } function closeReader() { if (!reader) return; saveProgress(); reader.hidden = true; reader.innerHTML = ''; document.documentElement.classList.remove('fd-reading'); readerItem = readerStory = null; render(); } // How far the open article is read (0-1); saved now and then and when leaving it. let progressSent = 0; let progressTimer = null; function readProgress() { if (!reader || reader.hidden) return 0; const room = reader.scrollHeight - reader.clientHeight; return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room); } function saveProgress() { const i = readerItem; if (!i) return; const p = Math.round(readProgress() * 100) / 100; if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return; progressSent = p; i.progress = p; st.items.forEach(x => { if (x.id === i.id) x.progress = p; }); if (p >= 0.9 && !i.read) { i.read = true; markLocal([i.id], true); } api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {}); } async function openItem(id, story) { ensureReader(); if (readerItem) saveProgress(); readerStory = story || null; reader.hidden = false; document.documentElement.classList.add('fd-reading'); reader.innerHTML = readerShell('

Lädt …

'); reader.scrollTop = 0; try { const data = await api('/feeds/items/' + id + '/'); readerItem = data.item; reader.innerHTML = readerShell(articleHtml(readerItem)); reader.scrollTop = 0; progressSent = readerItem.progress || 0; if (readerItem.kind === 'news') { // News is read once opened; a blog post once read to 90 % (the server keeps where). if (!readerItem.read) { readerItem.read = true; markLocal([id], true); api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {}); } } else { if (progressSent > 0.02 && progressSent < 0.9) { // Back where reading stopped (on any device). requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); }); } else { setTimeout(saveProgress, 1500); // short enough to fit: read at once } } } catch (e) { reader.innerHTML = readerShell('

' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '

'); } } function readerShell(inner) { const i = readerItem; return '
' + '' + '' + esc(i ? i.source_title : '') + '' + '' + (i ? '' : '') + (i && i.url ? '↗' : '') + '
' + inner + '
'; } function articleHtml(i) { let h = ''; const story = readerStory; if (story && story.items.length > 1) { h += '
' + story.items.length + ' Berichte:' + story.items.map(x => '').join('') + '
'; } h += '

' + esc(i.title) + '

' + '

' + [i.author, i.source_title, when(i.published)].filter(Boolean).map(esc).join(' · ') + '

'; const content = i.content || ''; if (i.image_url && content.indexOf(i.image_url) < 0) h += ''; h += '
' + (content || '

' + esc(i.summary) + '

') + '
'; if (i.paywalled) { h += '

Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) + ') holt diora den ganzen.

'; } else if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) { h += '

Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.

'; } if (story && story.items.length > 1) { h += '

Weitere Berichte

' + story.items.filter(x => x.id !== i.id).map(x => '').join(''); } h += '
'; if (i.url) h += 'Original öffnen ↗'; const next = nextItem(i); if (next) h += ''; h += '
'; return h; } function nextItem(i) { if (readerStory) return null; const list = readerList; const at = list.findIndex(x => x.id === i.id); for (let n = at + 1; n < list.length; n++) if (!list[n].read) return list[n]; return null; } function markLocal(ids, read) { st.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; }); st.stories.forEach(s => { s.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; }); s.unread = s.items.filter(i => !i.read).length; }); } function onReaderClick(e) { const b = e.target.closest('[data-r]'); if (!b) { // Links in the article open outside (the app hands them to the browser). const a = e.target.closest('.fd-art-body a[href]'); if (a) { a.target = '_blank'; a.rel = 'noopener'; } return; } const r = b.dataset.r; if (r === 'close') closeReader(); else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory); else if (r === 'font') { let v = fontSize() + 2; if (v > 26) v = 14; try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {} const art = reader.querySelector('.fd-art'); if (art) art.style.fontSize = v + 'px'; } else if (r === 'star' && readerItem) { readerItem.starred = !readerItem.starred; api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {}); b.textContent = readerItem.starred ? '★' : '☆'; b.classList.toggle('on', readerItem.starred); st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; }); } } async function openStory(id) { let story = st.stories.find(x => x.id === id); if (!story && st.briefing) { const all = [].concat(st.briefing.top, ...st.briefing.sections.map(s => s.stories)); story = all.find(x => x.id === id); } if (!story) return; readerList = []; const lead = story.lead || story.items[0]; // Opening a story reads it: all its reports count as read. const unread = story.items.filter(i => !i.read).map(i => i.id); if (unread.length) { markLocal(unread, true); story.unread = 0; api('/feeds/items/read/', {ids: unread, read: true}).catch(() => {}); } openItem(lead.id, story); } // ---- folders: rename, dissolve async function folderAction(act, name) { if (act === 'folder-rename') { const neu = typeof customPrompt === 'function' ? await customPrompt('Ordner „' + name + '“ umbenennen in:', name) : window.prompt('Neuer Name', name); if (!neu || !neu.trim() || neu.trim() === name) return; await api('/feeds/folders/rename/', {name, new: neu.trim()}).catch(() => {}); if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu.trim().slice(0, 40); } else { const ok = typeof customConfirm === 'function' ? await customConfirm('Ordner „' + name + '“ auflösen? Die Blogs bleiben abonniert.') : window.confirm('Auflösen?'); if (!ok) return; await api('/feeds/folders/dissolve/', {name}).catch(() => {}); if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null; } await loadState().catch(() => {}); if (st.view === 'blogs') return load(); render(); } tab.addEventListener('contextmenu', e => { const chip = e.target.closest('[data-folder]'); if (!chip || chip.dataset.folder === '*' || chip.dataset.folder === '') return; e.preventDefault(); folderSheet(chip.dataset.folder); }); // The long-press menu of a folder: open, rename, dissolve. function folderSheet(name) { const old = document.getElementById('fd-sheet'); if (old) old.remove(); const sheet = document.createElement('div'); sheet.id = 'fd-sheet'; sheet.innerHTML = '
' + esc(name) + '
' + '' + '' + '' + '
'; sheet.addEventListener('click', e => { const b = e.target.closest('[data-s]'); if (!b && e.target !== sheet) return; sheet.remove(); const what = b ? b.dataset.s : 'cancel'; if (what === 'open') { st.blogFolder = name; st.blogSource = ''; load(); } if (what === 'rename') folderAction('folder-rename', name); if (what === 'dissolve') folderAction('folder-dissolve', name); }); document.body.appendChild(sheet); } // ---- events tab.addEventListener('click', async e => { const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit],[data-folder],[data-flag],[data-edit-open]'); if (!t || t.tagName === 'SELECT' || (t.tagName === 'INPUT' && t.type !== 'button')) return; if (t.dataset.view) { st.view = t.dataset.view; try { localStorage.setItem('diora_feeds_view', st.view); } catch (err) {} load(); return; } if (t.dataset.flag) { const src = sources().find(x => x.id === parseInt(t.dataset.id, 10)); if (!src) return; src[t.dataset.flag] = !src[t.dataset.flag]; render(); await api('/feeds/sources/' + src.id + '/', {[t.dataset.flag]: src[t.dataset.flag]}).catch(() => {}); return load(); } if (t.dataset.editOpen) { st.view = 'manage'; st.editing = parseInt(t.dataset.editOpen, 10); window.scrollTo(0, 0); return render(); } if (t.dataset.folder !== undefined) { st.blogFolder = t.dataset.folder === '*' ? null : t.dataset.folder; st.blogSource = ''; window.scrollTo(0, 0); return load(); } if (t.dataset.story) return openStory(parseInt(t.dataset.story, 10)); if (t.dataset.item) { readerList = st.items; return openItem(parseInt(t.dataset.item, 10), null); } if (t.dataset.cat !== undefined && !t.dataset.act) { st.category = t.dataset.cat; window.scrollTo(0, 0); return load(); } if (t.dataset.edit) { st.editing = parseInt(t.dataset.edit, 10); return render(); } const act = t.dataset.act; if (act === 'refresh') { if (st.loading) return; st.loading = true; render(); try { await api('/feeds/refresh/', {}); } catch (err) {} return load(); } if (act === 'manage') { st.view = 'manage'; st.found = null; window.scrollTo(0, 0); if (!st.state) await loadState().catch(() => {}); return render(); } if (act === 'close-manage') { try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; } st.editing = null; st.opml = null; return load(); } if (act === 'seen') { t.disabled = true; try { await api('/feeds/briefing/seen/', {}); } catch (err) {} return load(); } if (act === 'more') return load(true); if (act === 'mode-unread' || act === 'mode-all') { st.blogUnread = act === 'mode-unread'; try { localStorage.setItem('diora_feeds_unread', st.blogUnread ? '1' : '0'); } catch (err) {} return load(); } if (act === 'mode-fav') { st.blogFav = !st.blogFav; return load(); } if (act === 'mode-rare') { st.rareFirst = !st.rareFirst; try { localStorage.setItem('diora_feeds_rare', st.rareFirst ? '1' : '0'); } catch (err) {} return render(); } if (act === 'blogs-read') { const ids = st.items.filter(i => !i.read).map(i => i.id); if (!ids.length) return; await api('/feeds/items/read/', {ids, read: true}).catch(() => {}); return load(); } if (act === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name); if (act === 'login-delete') { await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {}); await loadState().catch(() => {}); return render(); } if (act === 'cancel-edit') { st.editing = null; return render(); } if (act === 'delete') { const src = sources().find(x => x.id === parseInt(t.dataset.id, 10)); const ok = typeof customConfirm === 'function' ? await customConfirm('„' + (src ? src.title : 'Feed') + '“ abbestellen? Seine Artikel verschwinden mit.', {danger: true}) : window.confirm('Abbestellen?'); if (!ok) return; await api('/feeds/sources/' + t.dataset.id + '/delete/', {}).catch(() => {}); st.editing = null; await loadState().catch(() => {}); return render(); } if (act === 'opml-cancel') { st.opml = null; return render(); } if (act === 'opml-import') { const list = []; st.opml.forEach(g => { if (g.kind === 'skip') return; g.feeds.forEach(f => list.push({url: f.url, title: f.title, kind: g.kind, category: g.name})); }); t.disabled = true; t.textContent = 'Wird übernommen …'; try { const r = await api('/feeds/sources/bulk/', {sources: list}); st.opml = null; st.error = ''; await loadState(); render(); if (typeof customAlert === 'function') customAlert(r.added + ' Feeds übernommen.'); } catch (err) { st.error = err.message; render(); } } }); tab.addEventListener('change', async e => { const t = e.target; if (t.dataset.act === 'blog-source') { st.blogSource = t.value; return load(); } if (t.dataset.act === 'keep') { await api('/feeds/settings/', {news_keep_days: parseInt(t.value, 10)}).catch(() => {}); return; } if (t.dataset.opml !== undefined) { st.opml[parseInt(t.dataset.opml, 10)].kind = t.value; return render(); } if (t.dataset.act === 'opml' && t.files && t.files[0]) { // FileReader rather than File.text(): the e-reader's Chrome 61 lacks the latter. const text = await new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = () => reject(fr.error); fr.readAsText(t.files[0]); }); const groups = parseOpml(text); st.opml = groups.length ? groups : null; st.error = groups.length ? '' : 'In der Datei sind keine Feeds.'; render(); } }); tab.addEventListener('submit', async e => { e.preventDefault(); const f = e.target; const btn = f.querySelector('[type=submit]'); if (btn) btn.disabled = true; try { if (f.dataset.form === 'discover') { st.found = null; st.error = ''; if (btn) btn.textContent = 'Sucht …'; const r = await api('/feeds/discover/', {url: f.url.value}); st.found = r.feeds; } else if (f.dataset.form === 'subscribe') { const found = st.found[parseInt(f.dataset.n, 10)]; if (btn) btn.textContent = 'Lädt Artikel …'; await api('/feeds/sources/add/', {url: found.url, title: found.title, kind: f.kind.value, category: f.category.value}); found.subscribed = true; await loadState(); } else if (f.dataset.form === 'login') { if (btn) btn.textContent = 'Meldet an …'; const r = await api('/feeds/logins/', {source: parseInt(f.dataset.id, 10), username: f.username.value, password: f.password.value}); f.password.value = ''; await loadState(); if (typeof customAlert === 'function') { customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : '')); } } else if (f.dataset.form === 'edit') { const change = {title: f.title.value, kind: f.kind.value, fulltext: f.fulltext.value}; if (f.category) change.category = f.category.value; if (f.newfolder) { change.folders = Array.prototype.filter.call(f.querySelectorAll('input[name=folder]'), c => c.checked).map(c => c.value); if (f.newfolder.value.trim()) change.folders.push(f.newfolder.value.trim()); } if (f.fav) change.fav = f.fav.checked; if (f.hidden) change.hidden = f.hidden.checked; if (f.notify) change.notify = f.notify.checked; await api('/feeds/sources/' + f.dataset.id + '/', change); st.editing = null; await loadState(); } st.error = ''; } catch (err) { st.error = err.message; } render(); }); // The same field is the Ressort of news and the folder of a blog. tab.addEventListener('input', e => { if (e.target.name !== 'kind') return; const form = e.target.form; const cat = form && form.querySelector('.fd-cat-field'); if (cat && cat.firstChild) cat.firstChild.nodeValue = (e.target.value === 'news' ? 'Ressort' : 'Ordner') + ' '; }); // ---- showing the tab let shownAt = 0; window.__dioraFeedsShow = function () { // Back after a while: fresh state (new briefing); otherwise as it was. if (!st.state || Date.now() - shownAt > 5 * 60000) load(); else render(); shownAt = Date.now(); }; // What other parts (Home) may show: the briefing and the unread blog posts. window.__dioraFeedsSummary = async function () { const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]); return {state, briefing}; }; // A notification about a new post was tapped (the app, MainActivity): open it. window.__dioraFeedsOpenItem = function (id) { showTab('feeds'); readerList = st.items; openItem(id, null); }; window.__dioraFeedsOpenStory = function (story) { if (!st.stories.some(x => x.id === story.id)) st.stories.push(story); openStory(story.id); }; setInterval(() => { if (tab.style.display !== 'none' && !readerOpen() && st.view !== 'manage' && !st.loading) load(); }, 10 * 60000); // Back (Android): the article, then the management page, then (diora Reader) to the books. const back = window.__dioraBack; window.__dioraBack = function () { if (readerOpen()) { closeReader(); return true; } if (tab.style.display !== 'none' && st.view === 'manage') { tab.querySelector('[data-act="close-manage"]').click(); return true; } if (back && back()) return true; if (READER_APP && typeof _currentTopTab !== 'undefined' && _currentTopTab === 'feeds') { showTab('books'); return true; } return false; }; document.addEventListener('keydown', e => { if (e.key === 'Escape' && readerOpen()) closeReader(); }); // diora Reader has no tab bar of its own: a small one for books and feeds. if (READER_APP && !document.getElementById('nui-tabbar')) { const dock = document.createElement('div'); dock.id = 'nui-dock'; dock.innerHTML = ''; document.body.appendChild(dock); const mark = () => dock.querySelectorAll('button').forEach(b => b.classList.toggle('nui-active', typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab)); dock.addEventListener('click', e => { const b = e.target.closest('button'); if (!b) return; showTab(b.dataset.tab); window.scrollTo(0, 0); }); const orig = window.showTab; window.showTab = function (name) { orig.apply(this, arguments); mark(); }; document.documentElement.style.setProperty('--nui-dock-h', '56px'); const overlay = document.getElementById('reader-overlay'); if (overlay) { new MutationObserver(() => document.documentElement.classList.toggle('nui-reading', overlay.style.display !== 'none')) .observe(overlay, {attributes: true, attributeFilter: ['style']}); } mark(); } // ---- styles const style = document.createElement('style'); style.textContent = [ '#tab-feeds .fd { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }', '#tab-feeds .fd-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 14px; }', '#tab-feeds .fd-seg { display: flex; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; overflow: hidden; }', '#tab-feeds .fd-seg button { background: none; border: none; color: inherit; font: inherit; padding: 9px 14px; cursor: pointer; }', '#tab-feeds .fd-seg button.on { background: var(--accent, #e63946); color: #fff; }', '#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: rgba(255,255,255,0.18); font-size: 11px; text-align: center; }', '#tab-feeds .fd-actions { display: flex; gap: 4px; }', '.fd-icon { background: none; border: none; color: inherit; font: inherit; font-size: 20px; min-width: 40px; min-height: 40px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }', '.fd-icon.on { color: var(--accent, #e63946); }', '#tab-feeds .fd-back { background: none; border: none; color: inherit; font: inherit; padding: 8px 0; cursor: pointer; }', '#tab-feeds .fd-h { margin: 0; font-size: 15px; }', '.fd-note { color: var(--nui-muted, #9a9a9a); font-size: 13px; font-weight: 400; }', '.fd-error, .fd-warn { color: var(--accent, #e63946); font-size: 13px; }', '#tab-feeds .fd-empty { text-align: center; padding: 40px 0; }', '#tab-feeds h3 { margin: 18px 0 6px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', '#tab-feeds h3 small { font-size: 11px; }', '#tab-feeds .fd-brief { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 12px 14px 14px; margin-bottom: 18px; background: #0b0b0b; }', '#tab-feeds .fd-brief-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }', '#tab-feeds .fd-brief-head h2 { margin: 0; font-size: 16px; color: var(--accent, #e63946); }', '#tab-feeds .fd-seen { width: 100%; margin-top: 14px; min-height: 42px; }', '#tab-feeds .fd-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#tab-feeds .fd-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }', '#tab-feeds .fd-row-title { font-size: 14px; line-height: 1.35; }', '.fd-row-meta { font-size: 12px; font-weight: 400; color: var(--nui-muted, #9a9a9a); }', '#tab-feeds .fd-more-src { color: var(--accent, #e63946); }', '#tab-feeds .fd-badge { flex: none; min-width: 26px; height: 26px; border-radius: 13px; background: var(--accent, #e63946); color: #fff; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }', '#tab-feeds .fd-link { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; padding: 8px 0; cursor: pointer; }', '#tab-feeds .fd-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; align-items: center; }', // The reports of a story: one row that scrolls sideways, however many there are. '.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }', '.fd-sources::-webkit-scrollbar { display: none; }', '.fd-sources .fd-chip { flex: none; }', '#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', '#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }', '#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }', '.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', '.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }', '.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }', '.fd-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; }', '#tab-feeds .fd-modes .fd-chip { font-size: 12px; padding: 5px 10px; }', '#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }', '#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: #fff; font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }', '#tab-feeds .fd-hidden { opacity: 0.6; }', '#tab-feeds .fd-prog { display: block; height: 3px; margin-top: 4px; background: #2a2a2a; border-radius: 2px; overflow: hidden; }', '#tab-feeds .fd-prog > span { display: block; height: 100%; background: var(--accent, #e63946); }', '#tab-feeds .fd-folders { border: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }', '#tab-feeds .fd-folders legend { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin-bottom: 4px; }', '#tab-feeds .fd-folders input[name=newfolder] { flex-basis: 100%; }', '#tab-feeds .fd-folder-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); flex-wrap: wrap; }', '#fd-sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 995; background: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; }', '#fd-sheet .fd-sheet-box { width: 100%; max-width: 480px; background: var(--bg, #000); border: 1px solid var(--border, #2a2a2a); border-bottom: none; border-radius: 10px 10px 0 0; padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); }', '#fd-sheet .fd-sheet-title { padding: 10px 18px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', '#fd-sheet button { display: block; width: 100%; padding: 14px 18px; background: none; border: none; color: var(--fg, #fff); font: inherit; text-align: left; cursor: pointer; }', 'body.eink #tab-feeds .fd-prog > span { background: #000; }', 'body.eink #fd-sheet .fd-sheet-box { background: #fff; }', 'body.eink #fd-sheet button { color: #000; }', 'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }', '#tab-feeds .fd-day { margin-top: 16px; }', '#tab-feeds .fd-card { display: flex; gap: 12px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#tab-feeds .fd-thumb { width: 92px; height: 66px; flex: none; object-fit: cover; border-radius: 4px; background: #1a1a1a; order: 2; }', '#tab-feeds .fd-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }', '#tab-feeds .fd-card-title { font-size: 15px; font-weight: 700; line-height: 1.35; }', '#tab-feeds .fd-card-sum { font-size: 13px; font-weight: 400; line-height: 1.4; color: var(--nui-muted, #bdbdbd); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }', '#tab-feeds .fd-read .fd-card-title, #tab-feeds .fd-read .fd-row-title { font-weight: 400; color: var(--nui-muted, #9a9a9a); }', '#tab-feeds .fd-post { display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#tab-feeds .fd-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }', '#tab-feeds .fd-select { max-width: 60%; }', '#tab-feeds .fd-check { font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }', '#tab-feeds .fd-allread { margin-left: auto; }', '#tab-feeds .fd-loadmore { width: 100%; margin-top: 14px; }', '#tab-feeds .fd-box { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 4px 14px 14px; margin-bottom: 14px; }', '#tab-feeds .fd-add { display: flex; gap: 8px; }', '#tab-feeds .fd-add input { flex: 1; min-width: 0; }', '#tab-feeds .fd-found, #tab-feeds .fd-edit { border-top: 1px solid var(--border, #2a2a2a); margin-top: 12px; padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }', '#tab-feeds .fd-found-title { font-weight: 700; }', '#tab-feeds .fd-url { word-break: break-all; }', '#tab-feeds .fd-samples { margin: 0; padding-left: 18px; font-size: 13px; font-weight: 400; }', '#tab-feeds .fd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }', '#tab-feeds .fd-grid label, #tab-feeds .fd-edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }', '#tab-feeds .fd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }', '#tab-feeds .fd-login h4 { margin: 0; font-size: 13px; }', '#tab-feeds .fd-login p { margin: 0; font-size: 13px; }', '#tab-feeds .fd-src { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#tab-feeds .fd-opml { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }', '#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }', '#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }', '#tab-feeds .fd-file { display: inline-block; }', // The article: a page over everything, like the book reader. '#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }', '#fd-article[hidden] { display: none; }', '#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }', '#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', '#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }', '#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }', '#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }', '#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }', '#fd-article .fd-art-body a { color: var(--accent, #e63946); }', '#fd-article .fd-art-body figure { margin: 12px 0; }', '#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }', '#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border, #444); }', '#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }', '#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }', '#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }', '#fd-article .fd-sources { font-family: var(--font); }', '#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }', 'html.fd-reading #nui-dock { display: none; }', '@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: #111; } }', // Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker. 'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }', 'body.eink #tab-feeds .fd-thumb { display: none; }', 'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }', 'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }', 'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }', 'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }', ].join('\n'); document.head.appendChild(style); })();