-
Notifications
You must be signed in to change notification settings - Fork 897
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
1,956 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
/** | ||
* Fetch recent toots for a user, given their Mastodon URL. | ||
*/ | ||
export async function getToots(userURL, accountId, limit, excludeReplies) { | ||
const url = new URL(userURL); | ||
// Either use the account id specified or look it up based on the username | ||
// in the link. | ||
const userId = accountId ?? | ||
(await (async () => { | ||
// Extract username from URL. | ||
const parts = /@(\w+)$/.exec(url.pathname); | ||
if (!parts) { | ||
throw "not a Mastodon user URL"; | ||
} | ||
const username = parts[1]; | ||
// Look up user ID from username. | ||
const lookupURL = Object.assign(new URL(url), { | ||
pathname: "/api/v1/accounts/lookup", | ||
search: `?acct=${username}`, | ||
}); | ||
return (await (await fetch(lookupURL)).json())["id"]; | ||
})()); | ||
// Fetch toots. | ||
const tootURL = Object.assign(new URL(url), { | ||
pathname: `/api/v1/accounts/${userId}/statuses`, | ||
search: `?limit=${limit ?? 5}&exclude_replies=${!!excludeReplies}`, | ||
}); | ||
return await (await fetch(tootURL)).json(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import { getToots } from "./client.js"; | ||
import DOMPurify from "../dompurify/purify.es.js"; | ||
/** | ||
* Mark a string as safe for inclusion in HTML. | ||
*/ | ||
function safe(s) { | ||
return Object.assign(new String(s), { __safe: null }); | ||
} | ||
/** | ||
* Format a value as a string for templating. | ||
*/ | ||
function flat(v) { | ||
if (typeof v === "undefined" || v === null) { | ||
return ""; | ||
} | ||
else if (typeof v === "string" || v instanceof String) { | ||
if (v.hasOwnProperty("__safe")) { | ||
return v; | ||
} | ||
else { | ||
// Escape strings for inclusion in HTML. | ||
return v | ||
.replaceAll("&", "&") | ||
.replaceAll("<", "<") | ||
.replaceAll(">", ">") | ||
.replaceAll('"', """) | ||
.replaceAll("'", "'"); | ||
} | ||
} | ||
else { | ||
return v.map(flat).join(""); | ||
} | ||
} | ||
/** | ||
* The world's dumbest templating system. | ||
*/ | ||
function html(strings, ...subs) { | ||
let out = strings[0]; | ||
for (let i = 1; i < strings.length; ++i) { | ||
out += flat(subs[i - 1]); | ||
out += strings[i]; | ||
} | ||
return safe(out); | ||
} | ||
/** | ||
* Render a single toot object as an HTML string. | ||
*/ | ||
function renderToot(toot) { | ||
// Is this a boost (reblog)? | ||
let boost = null; | ||
if (toot.reblog) { | ||
boost = { | ||
avatar: toot.account.avatar, | ||
username: toot.account.username, | ||
display_name: toot.account.display_name, | ||
user_url: toot.account.url, | ||
}; | ||
toot = toot.reblog; // Show the "inner" toot instead. | ||
} | ||
const date = new Date(toot.created_at).toLocaleString(); | ||
const images = toot.media_attachments.filter((att) => att.type === "image"); | ||
return html `<li class="toot"> | ||
<a class="permalink" href="${toot.url}"> | ||
<time datetime="${toot.created_at}">${date}</time> | ||
</a> | ||
${boost && | ||
html ` <a class="user boost" href="${boost.user_url}"> | ||
<img class="avatar" width="23" height="23" src="${boost.avatar}" /> | ||
<span class="display-name">${boost.display_name}</span> | ||
<span class="username">@${boost.username}</span> | ||
</a>`} | ||
<a class="user" href="${toot.account.url}"> | ||
<img class="avatar" width="46" height="46" src="${toot.account.avatar}" /> | ||
<span class="display-name">${toot.account.display_name}</span> | ||
<span class="username">@${toot.account.username}</span> | ||
</a> | ||
<div class="body">${safe(DOMPurify.sanitize(toot.content))}</div> | ||
${images.map((att) => html ` <a | ||
class="attachment" | ||
href="${att.url}" | ||
target="_blank" | ||
rel="noopener noreferrer" | ||
> | ||
<img | ||
class="attachment" | ||
src="${att.preview_url}" | ||
alt="${att.description}" | ||
/> | ||
</a>`)} | ||
</li>`.toString(); | ||
} | ||
/** | ||
* Get the toots for an HTML element and replace that element with the | ||
* rendered toot list. | ||
*/ | ||
export async function loadToots(element) { | ||
// Fetch toots based on the element's `data-toot-*` attributes. | ||
const el = element; | ||
const toots = await getToots(el.href, el.dataset.tootAccountId, Number(el.dataset.tootLimit ?? 5), el.dataset.excludeReplies === "true"); | ||
// Construct the HTML content. | ||
const list = document.createElement("ol"); | ||
list.classList.add("toots"); | ||
el.replaceWith(list); | ||
for (const toot of toots) { | ||
const html = renderToot(toot); | ||
list.insertAdjacentHTML("beforeend", html); | ||
} | ||
} | ||
/** | ||
* Transform all links on the page marked with the `mastodon-feed` class. | ||
*/ | ||
export function loadAll() { | ||
document.querySelectorAll("a.mastodon-feed").forEach(loadToots); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { loadAll } from "./core.js"; | ||
loadAll(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
.toots { | ||
max-width: 400px; | ||
list-style: none; | ||
padding: 0; | ||
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif; | ||
} | ||
|
||
.toot { | ||
border-bottom: 1px solid #ccc; | ||
padding: 1rem; | ||
} | ||
|
||
/* Posting user. */ | ||
.toot .user { | ||
display: flex; | ||
flex-flow: column wrap; | ||
justify-content: space-evenly; | ||
align-content: flex-start; | ||
height: 46px; /* Avatar height. */ | ||
column-gap: 0.5rem; | ||
|
||
text-decoration: none; | ||
color: inherit; | ||
} | ||
|
||
.toot .avatar { | ||
border-radius: 4px; | ||
} | ||
|
||
.toot .display-name { | ||
font-weight: bold; | ||
display: block; | ||
} | ||
|
||
.toot .user:hover .display-name { | ||
text-decoration: underline; | ||
} | ||
|
||
.toot .username { | ||
display: block; | ||
margin-right: 1em; | ||
color: #999; | ||
} | ||
|
||
/* Boosting user is smaller and above the posting user. */ | ||
.toot .boost { | ||
height: 23px; | ||
margin-bottom: 0.25rem; | ||
column-gap: 0.25rem; | ||
} | ||
|
||
.toot .boost:before { | ||
content: "♺"; | ||
font-size: 140%; | ||
} | ||
|
||
.toot .boost .username { | ||
display: none; | ||
} | ||
|
||
.toot .permalink { | ||
text-decoration: none; | ||
display: block; | ||
color: #999; | ||
float: right; | ||
} | ||
|
||
.toot .permalink:hover { | ||
text-decoration: underline; | ||
} | ||
|
||
.toot .body { | ||
clear: both; | ||
margin-top: 1em; | ||
} | ||
|
||
.toot .body a { | ||
overflow-wrap: anywhere; | ||
} | ||
|
||
/* Weird trick to keep the text in the page but not display it. */ | ||
.toot .body .invisible { | ||
display: inline-block; | ||
font-size: 0; | ||
line-height: 0; | ||
width: 0; | ||
height: 0; | ||
position: absolute; | ||
} | ||
|
||
.toot .body .ellipsis::after { | ||
content: "…"; | ||
} | ||
|
||
.toot .attachment { | ||
display: block; | ||
width: 100%; | ||
aspect-ratio: 16 / 9; | ||
border-radius: 4px; | ||
} | ||
|
||
.toot .attachment img { | ||
width: 100%; | ||
height: 100%; | ||
object-fit: cover; | ||
} |