summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.js328
-rw-r--r--index.html12
-rw-r--r--masto.js34
-rw-r--r--treed.css163
4 files changed, 537 insertions, 0 deletions
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..d024a8c
--- /dev/null
+++ b/app.js
@@ -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%; }