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; if (post.reblog) post = post.reblog; 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", { onclick: (ev) => { // Don't jump around when closing an account which we see // only due to position:sticky; let el = ev.currentTarget.parentElement; let el_y = el.getBoundingClientRect().top; if (el_y < 0) { window.scrollTo(window.scrollX, window.scrollY + el_y); } } }, [ 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"), ]) ]; } }; 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"); } 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(); } }