diff options
-rw-r--r-- | app.js | 328 | ||||
-rw-r--r-- | index.html | 12 | ||||
-rw-r--r-- | masto.js | 34 | ||||
-rw-r--r-- | treed.css | 163 |
4 files changed, 537 insertions, 0 deletions
@@ -0,0 +1,328 @@ +import { Mastodon } from "./masto.js"; + +function strftime(s, date) { + if (!(date instanceof Date)) { + date = new Date(date); + } + let pad = (n, width) => String(n).padStart(width, '0'); + return s.replaceAll(/%./g, (match) => {switch (match[1]) { + case 'Y': return pad(date.getFullYear(), 4); + case 'm': return pad(date.getMonth()+1, 2); + case 'd': return pad(date.getDate(), 2); + case 'H': return pad(date.getHours(), 2); + case 'M': return pad(date.getMinutes(), 2); + default: return match; + }}); +} + +function date_add(date, days) { + date = new Date(date); + date.setDate(date.getDate() + days); + return strftime("%Y-%m-%d", date); +} + +let post_tree = {}; +let posts_raw = []; + +let active_day = date_add(new Date(), -1); + +let Post = { + oninit: function(vnode) { + let post = vnode.attrs.post; + vnode.state.chuj = post.spoiler_text; + vnode.state.collapsed = post.spoiler_text.length > 0; + }, + view: function(vnode) { + let post = vnode.attrs.post; + let original = post; + if (post.reblog) post = post.reblog; + + let time = (original == post) + ? strftime("%H:%M", post.created_at) + : strftime("%Y-%m-%d %H:%M", post.created_at); + + let spoiler_el = + post.spoiler_text + ? m("span.toot__spoiler.flex-grow", { + "class": vnode.state.collapsed ? "" : "toot__spoiler_open", + onclick: () => {vnode.state.collapsed = !vnode.state.collapsed} + }, post.spoiler_text) + : m("span.flex-grow"); + + let buttons = [ + // m("span.material-icons.fg2.cursor-pointer", { + // onclick: () => {console.log(original, vnode);}, + // }, "pest_control"), + m("span.material-icons.fg2", { + title: post.visibility + }, { + "public": "public", + "unlisted": "bedtime", + "private": "lock", + "direct": "alternate_email", + }[post.visibility]), + ]; + + let header = null; + if (original.reblog) { + header = m("div.flex.flex-align-center.border-bottom.px-1em", [ + m("img.avatar.avatar-reblog", {src: post.account.avatar_static}), + + m("div.flex.flex-grow.flex-column.py-025em", [ + + m("div.flex", [ + m("span.flex-grow", [ + m("span.fg2", "RT "), + m("a", {href: post.account.url}, post.account.display_name) + ]), + buttons + ]), + + m("div.flex", [ + m("a.fg2.link-stealth", + {href: post.url}, + strftime("%Y-%m-%d %H:%M", post.created_at) + ), + spoiler_el, + ]), + + ]), + ]); + } else { + header = m("div.flex.border-bottom.border-sunk", [ + m("div.flex.flex-grow.flex-column.px-1em", [ + m("div.flex.py-025em", [ + m("a.fg2.link-stealth", + {href: post.url}, + strftime("%H:%M", post.created_at) + ), + spoiler_el, + buttons + ]), + ]), + ]); + } + + let media = post.media_attachments; + + return m("div.toot", [ + // TODO: mark replies + + header, + + vnode.state.collapsed || m("div.px-1em.py-025em", [ + post.in_reply_to_id != null && m("div.fg2", [ + `(reply to something. dunno what)`, + ]), + + m.trust(post.content), + + (media.length > 0) && m("div.pt-1em", + // TODO properly handle non-image attachments + post.media_attachments.map(ma => + m("details.toot__media", [ + m("summary", [ + m("span.material-icons.fg2", "image"), + ma.description || "no alt text :(", + ]), + m("img[loading=lazy]", {src: ma.preview_url}) + ]) + ) + ), + ]), + ]); + } +} + +let DayDigest = { + view: function() { + let day_tree = post_tree[active_day] || {}; + + let users = Object.keys(day_tree); + + // Put local users first, then sort alphabetically. + users.sort((a, b) => { + let isLocal = (nick) => nick.indexOf('@') == -1; + if (isLocal(a) && !isLocal(b)) return -1; + if (!isLocal(a) && isLocal(b)) return 1; + if (a < b) return -1; + if (a > b) return 1; + return 0; + }); + + return users.map((user) => { + let posts = Object.values(day_tree[user]); + + // Sort by own toots first, then by time. + // TODO: allow configuring this. + posts.sort((a, b) => { + if (a.reblog && !b.reblog) return 1; + if (!a.reblog && b.reblog) return -1; + if (a.created_at < b.created_at) return -1; + if (a.created_at > b.created_at) return 1; + return 0; + }); + + let own_posts = 0, reblogs = 0, replies = 0; + for (let post of posts) { + if (post.reblog) reblogs++; + else if (post.in_reply_to_id) replies++; + else own_posts++; + } + + if (posts.length == 0) return; + + let account = posts[0].account; + + return m("details.acct", {key: user}, [ + m("summary.acct__header", [ + m("img.avatar", {src: account.avatar_static}), + m("span.acct__username", account.display_name || account.acct), // TODO figure out where to show acct + m("span.acct__counts.fg2", `${own_posts}o | ${replies}r | ${reblogs}b`), + ]), + posts.map(post => m(Post, {post, key: post.id})) + ]) + }); + } +} + +let Main = { + view: function() { + return [ + m("header", [ + m("button", { + onclick: () => { + if (confirm("Are you sure you want to logout?")) { + localStorage.removeItem("oauth_app"); + localStorage.removeItem("access_token"); + window.location.reload(); + } + } + }, "Logout"), + m("span.px-1em", `${posts_raw.length} posts loaded...`), + m("div.flex", [ + m("a.flex-grow.flex-basis-0.link-stealth.text-right", { + onclick: () => { + // TODO ensure that day is "sensible" + active_day = date_add(active_day, 1); + }, + href: "javascript:" + }, "newer"), + m("span.px-1em", active_day), + m("a.flex-grow.flex-basis-0.link-stealth", { + onclick: () => { + // TODO ensure that day is "sensible" + active_day = date_add(active_day, -1); + }, + href: "javascript:" + }, "earlier"), + ]), + m("hr"), + ]), + m("main", m(DayDigest)), + m("footer", [ + m("hr"), + m("p.fg2", [ + "(a very early version of) treed 2. ", + m("a", {href: "#"}, "go to top") + ]), + ]) + ]; + } +}; + +let Login = { + view: function() { + return [ + m("h1.text-center", "Treed"), + m("form.mw-40ch", { + onsubmit: async (e) => { + e.preventDefault(); + + let redir = window.location.href.split('#')[0]; + + let instance = document.getElementById("instance").value; + let masto = new Mastodon(instance, null); + localStorage.instance = instance; + + let res = await masto.post('/api/v1/apps', { + // TODO change + client_name: 'Treed 2', + redirect_uris: [redir], + scopes: 'read', + }); + localStorage.oauth_app = JSON.stringify(res); + // TODO reuse creds if possible + //let res = JSON.parse(localStorage.oauth_app); + // TODO error handling + window.location = masto.instance + '/oauth/authorize?' + new URLSearchParams({ + client_id: res.client_id, + scope: 'read', + redirect_uri: redir, + response_type: 'code' + }); + } + }, [ + m("label.form-label", "Your instance"), + m("input#instance.w-100.mb-05em[autofocus]", {placeholder: "mastodon.example"}), + m("button[type=submit]", "Login"), + m("p", "note that i'm not 100% sure if i'm doing oauth correctly. your token might leak. sorry.") + ]) + ]; + } +}; + + +let oauth_code = new URLSearchParams(window.location.search).get('code'); +if (oauth_code) { + // TODO error handling + let oauth = JSON.parse(localStorage.oauth_app); + // TODO setter for token + let masto = new Mastodon(localStorage.instance, null); + let res = await masto.post('/oauth/token', { + client_id: oauth.client_id, + client_secret: oauth.client_secret, + redirect_uri: oauth.redirect_uri, + grant_type: 'authorization_code', + code: oauth_code, + scope: 'read', + }); + localStorage.access_token = res.access_token; + + window.location.search = ''; +} + +m.route(document.body, "/home", { + "/home": Main, + "/login": Login, +}); + +if (!localStorage.instance || !localStorage.access_token) { + m.route.set("/login"); + // TODO +} else { + m.route.set("/home"); + let masto = new Mastodon(localStorage.instance, localStorage.access_token); + let max_id = null; + for (let i = 0; i < 20; i++) { + let opts = {limit: 100}; + if (max_id !== null) opts.max_id = max_id; + let posts = await masto.get("/api/v1/timelines/home", opts); + + if (posts.length == 0) break; + + for (const post of posts) { + const date = strftime("%Y-%m-%d", post.created_at); + const acct = post.account.acct; + if (!post_tree[date]) post_tree[date] = {}; + if (!post_tree[date][acct]) post_tree[date][acct] = {}; + post_tree[date][acct][post.id] = post; + + if (max_id === null || post.id < max_id) { + max_id = post.id; + } + posts_raw.push(post); + } + m.redraw(); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..b99eee6 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"> + <link rel="stylesheet" href="treed.css"> + <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> +</head> +<body> + <script src="https://unpkg.com/mithril/mithril.js"></script> + <script type="module" src="/app.js"></script> +</body> diff --git a/masto.js b/masto.js new file mode 100644 index 0000000..773ba9a --- /dev/null +++ b/masto.js @@ -0,0 +1,34 @@ +export class Mastodon { + constructor(instance, token) { + if (instance.indexOf('://') == -1) { + instance = 'https://' + instance; + } + this.instance = instance; + this.token = token; + } + + async get(endpoint, options) { + let url = this.instance + endpoint + '?' + new URLSearchParams(options); + const res = await fetch(url, { + headers: { + 'Authorization': 'Bearer ' + this.token, + }, + }); + if (res.status == 401) { + throw 401; + } + return res.json(); + } + + async post(endpoint, body) { + const fd = new FormData(); + for (let key in body) { + fd.append(key, body[key]); + } + const res = await fetch(this.instance + endpoint, { + method: 'POST', + body: fd, + }); + return res.json(); + } +} diff --git a/treed.css b/treed.css new file mode 100644 index 0000000..b7dbc09 --- /dev/null +++ b/treed.css @@ -0,0 +1,163 @@ +* { + box-sizing: border-box; + margin: 0; + font-family: sans-serif; + line-height: 1.5; + + /* ripping off masto */ + --border-color: #313144; + --background: #181821; + --background2: #20202c; + --foreground: #fff; + --foreground2: #c4c4de; + --links: #9494ff; +} + +body { + max-width: 80ch; + padding-bottom: 100vh; /* don't jump around when collapsing people near the bottom */ + margin: 1em auto; + background: var(--background); + color: var(--foreground); +} + +.acct { + margin-top: .75em; + margin-bottom: .75em; + border: 1px solid var(--border-color); + background: var(--background2); +} + +.acct__header { + display: flex; + align-items: center; + + /* TODO + background: #fff; + position: sticky; + top: 0; + */ + border-bottom: 1px solid var(--border-color); + margin-bottom: -1px; + + cursor: pointer; + user-select: none; +} + +.acct__header .acct__username { + margin-left: 1em; + + /* Mimic the default <details> style. */ + display: list-item; + list-style: disclosure-closed inside; +} + +.acct[open] .acct__header .acct__username { + list-style: disclosure-open inside; +} + +.acct__counts { + margin-left: auto; + margin-right: 1ch; +} + +.avatar { + width: 3em; + height: 3em; + border: 1px solid var(--border-color); + margin: -1px; + background: var(--background2); +} + +.avatar-reblog { + margin-right: 8px; + border-radius: 8px; +} + +pre { + white-space: pre-wrap; +} + +.toot { + background: var(--background); + border: 1px solid var(--border-color); + /* padding: .2em 1em; */ + padding: 0; + overflow: auto; + margin: .75em; +} + +.toot__body { + margin: .2em 1em; +} + +.toot__spoiler { + cursor: pointer; + display: list-item inline; + list-style: disclosure-closed inside; + margin-left: 1em; +} + +.toot__spoiler_open { + list-style: disclosure-open inside; +} + + +.flex { display: flex; } +.flex-wrap { flex-wrap: wrap; } +.flex-grow { flex-grow: 1; } +.flex-basis-0 { flex-basis: 0; } +.flex-column { flex-direction: column; } +.flex-align-center { align-items: center; } + +a { color: var(--links); } + +.px-1em { + padding-left: 1em; + padding-right: 1em; +} +.pr-1em { padding-right: 1em; } +.py-025em { + padding-top: .25em; + padding-bottom: .25em; +} +.pt-1em { padding-top: 1em; } + +.mb-05em { margin-bottom: .5em; } + +.border-bottom { border-bottom: 1px solid var(--border-color); } +.border-bottom.border-sunk { margin-bottom: -1px; } + +.fg2 { color: var(--foreground2); } + +/* This class is already defined by, well, Material Icons. + * I'm just making some small changes. */ +.material-icons { + font-size: inherit !important; + line-height: inherit !important; + user-select: none; + vertical-align: bottom; +} + +.link-stealth { text-decoration: none; } +.link-stealth:hover { text-decoration: underline; } + +hr { border: 1px solid var(--border-color); } + +.text-center { text-align: center; } +.text-right { text-align: right; } + +.cursor-pointer { cursor: pointer; } + +.form-label { + display: block; + font-size: .8rem; +} + +.mw-40ch { + max-width: 40ch; + margin: auto; +} +.w-100 { width: 100%; } + +img { max-width: 100%; } |