federating my verse (iceshrimp & mastodon API compat, read-only)

This commit is contained in:
ari melody 2024-06-17 21:17:27 +01:00
parent 8dc8190cdf
commit da93978820
Signed by: ari
GPG key ID: CF99829C92678188
67 changed files with 2743 additions and 649 deletions

154
src/App.svelte Normal file
View file

@ -0,0 +1,154 @@
<script>
import Feed from './Feed.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
let ready = false;
if (localStorage.getItem("fedi_host") && localStorage.getItem("fedi_token")) {
Instance.setup(
localStorage.getItem("fedi_host"),
localStorage.getItem("fedi_token"),
true
).then(() => {
ready = true;
});
}
function log_in(event) {
event.preventDefault();
localStorage.setItem("fedi_host", event.target.instance_host.value);
localStorage.setItem("fedi_token", event.target.session_token.value);
location = location;
}
function log_out() {
localStorage.removeItem("fedi_host");
localStorage.removeItem("fedi_token");
location = location;
}
</script>
<header>
<h1>space social</h1>
<p>social media for the galaxy-wide-web! 🌌</p>
<button id="log-out" on:click={log_out}>log out</button>
</header>
<main>
{#if ready}
<Feed />
{:else if !Instance.get_instance().ok}
<Error>
<p>this app requires a <strong>instance host</strong> and <strong>session token</strong> to work! you may enter these below:</p>
<form on:submit={data => (log_in(data))}>
<label for="instance host">instance host: </label>
<input type="text" id="instance_host">
<br>
<label for="session token">session token: </label>
<input type="password" id="session_token">
<br>
<button type="submit" id="log-in">log in</button>
</form>
<hr>
<h4>how do i get these?</h4>
<ul>
<li>
<strong>instance host</strong> refers to the domain of your fediverse instance. i.e. <code>ice.arimelody.me</code>.
</li>
<li>
a <strong>token</strong> is a unique code that grants applications permission to act on your behalf.
you can find it in your browser's cookies for your instance.
(instructions for <a href="https://support.mozilla.org/en-US/questions/1219653">firefox</a>
and <a href="https://superuser.com/questions/1715037/how-can-i-view-the-content-of-cookies-in-chrome">chrome</a>)
</li>
</ul>
<p><small>
your login credentials will not be saved to an external server.
they are required for communication with the fediverse instance, and are saved entirely within your browser.
a cleaner login flow will be built in the future.
</small></p>
<p><small>
oh yeah i should also probably mention this is <strong><em>extremely experimental software</em></strong>;
even if you use the exact same instance as me, you may encounter problems.
if that's all cool with you, welcome aboard!
</small></p>
<p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</Error>
{/if}
</main>
<footer>
</footer>
<style>
header {
width: min(768px, calc(100vw - 32px));
margin: 16px auto;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
}
h1 {
color: var(--accent);
margin: 0 16px 0 0;
}
main {
width: min(800px, calc(100vw - 16px));
margin: 0 auto;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
input[type="text"], input[type="password"] {
margin-bottom: 8px;
padding: 4px 6px;
font-family: inherit;
border: none;
border-radius: 8px;
}
button#log-in, button#log-out {
margin-left: auto;
padding: 8px 12px;
font-size: 1em;
background-color: var(--bg2);
color: inherit;
border: none;
border-radius: 16px;
cursor: pointer;
transition: color .1s, background-color .1s;
}
button#log-in.active, button#log-out.active {
background: var(--accent);
color: var(--bg0);
}
button#log-in:hover, button#log-out:hover {
color: var(--bg0);
background: var(--fg0);
}
button#log-in:active, button#log-out:active {
background: #0001;
}
code {
font-size: 1.2em;
}
</style>

26
src/Error.svelte Normal file
View file

@ -0,0 +1,26 @@
<script>
export let msg = "";
export let trace = "";
</script>
<div class="error">
{#if msg}
<p class="msg">{@html msg}</p>
{/if}
{#if trace}
<pre class="trace">{trace}</pre>
{/if}
<slot></slot>
</div>
<style>
.error {
margin-top: 16px;
padding: 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
}
</style>

49
src/Feed.svelte Normal file
View file

@ -0,0 +1,49 @@
<script>
import Post from './post/Post.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
let posts = [];
let loading = false;
let error;
async function load_posts() {
if (loading) return; // no spamming!!
loading = true;
let new_posts = [];
if (posts.length === 0) new_posts = await Instance.get_timeline()
else new_posts = await Instance.get_timeline(posts[posts.length - 1].id);
if (!new_posts) {
error = `sorry! the frontend is unable to communicate with your server.
this app is still in very early development, and is currently only built to support iceshrimp.
for more information, please consult the developer console.`;
loading = false;
return;
}
posts = [...posts, ...new_posts];
loading = false;
}
load_posts();
document.addEventListener("scroll", event => {
if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
load_posts();
}
});
</script>
<div id="feed">
{#if error}
<Error msg={error.replaceAll('\n', '<br>')} />
{/if}
{#each posts as post}
<Post post={post} />
{/each}
</div>

20
src/app.css Normal file
View file

@ -0,0 +1,20 @@
@import url("/font/inter/inter.css");
:root {
--fg0: #eee;
--bg0: #080808;
--bg1: #101010;
--bg2: #121212;
--accent: #b7fd49;
--accent-bg: #242b1a;
}
body {
margin: 0;
padding: 0;
color: var(--fg0);
background-color: var(--bg0);
font-family: "Inter", sans-serif;
}

93
src/emoji.js Normal file
View file

@ -0,0 +1,93 @@
import Instance from './instance.js';
const EMOJI_REGEX = /:[a-z0-9_\-]+:/g;
let emoji_cache = [];
export default class Emoji {
name;
host;
url;
width;
height;
static parse(data, host) {
const instance = Instance.get_instance();
let emoji = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
emoji = Emoji.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
emoji = Emoji.#parse_mastodon(data);
break;
default:
break;
}
if (emoji !== null) emoji_cache.push(emoji);
return emoji;
}
static #parse_iceshrimp(data, host) {
let emoji = new Emoji()
emoji.name = data.name.substring(1, data.name.search('@'));
emoji.host = host;
emoji.url = data.url;
emoji.width = data.width;
emoji.height = data.height;
return emoji;
}
static #parse_mastodon(data, host) {
let emoji = new Emoji()
emoji.name = data.shortcode;
emoji.host = host;
emoji.url = data.url;
emoji.width = data.width;
emoji.height = data.height;
return emoji;
}
get id() {
return this.name + '@' + this.host;
}
}
export function parse_text(text, ignore_instance) {
if (!text) return text;
let index = text.search(EMOJI_REGEX);
if (index === -1) return text;
index++;
// find the emoji name
let length = 0;
while (index + length < text.length && text[index + length] !== ':') length++;
let emoji_name = ':' + text.substring(index, index + length) + ':';
// does this emoji exist?
let emoji;
for (let cached in emoji_cache) {
if (cached.id === emoji_name) {
emoji = cached;
break;
}
}
if (!emoji) return text.substring(0, index + length) + parse_text(text.substring(index + length));
// replace emoji code with <img>
const img = `<img src="${emoji.url}" class="emoji" width="26" height="26" title=":${emoji_name}:" alt="${emoji_name}">`;
return text.substring(0, index - 1) + img +
parse(text.substring(index + length + 1), emojis, ignore_instance);
}
export function parse_one(reaction, emojis) {
if (reaction == '❤') return '❤️'; // stupid heart unicode
if (!reaction.startsWith(':') || !reaction.endsWith(':')) return reaction;
for (let i = 0; i < emojis.length; i++) {
if (emojis[i].name == reaction.substring(1, reaction.length - 1))
return `<img src="${emojis[i].url}" class="emoji" width="26" height="26" title="${reaction}" alt="${emojis[i].name}">`;
}
return reaction;
}

107
src/instance.js Normal file
View file

@ -0,0 +1,107 @@
import Post from './post/post.js';
let instance;
const ERR_UNSUPPORTED = "Unsupported server";
const ERR_SERVER_RESPONSE = "Unsupported response from the server";
export default class Instance {
#host;
#token;
#type;
#secure;
static types = {
ICESHRIMP: "iceshrimp",
MASTODON: "mastodon",
MISSKEY: "misskey",
AKKOMA: "akkoma",
};
static get_instance() {
if (!instance) instance = new Instance();
return instance;
}
static async setup(host, token, secure) {
instance = Instance.get_instance();
instance.host = host;
instance.token = token;
instance.secure = secure;
await instance.#guess_type();
}
async #guess_type() {
const url = instance.#proto + instance.host + "/api/v1/instance";
console.log("Snooping for instance information at " + url + "...");
const res = await fetch(url);
const data = await res.json();
const version = data.version.toLowerCase();
instance.type = Instance.types.MASTODON;
if (version.search("iceshrimp") !== -1) instance.type = Instance.types.ICESHRIMP;
if (version.search("misskey") !== -1) instance.type = Instance.types.MISSKEY;
if (version.search("akkoma") !== -1) instance.type = Instance.types.AKKOMA;
console.log("Assumed server type to be \"" + instance.type + "\".");
}
static async get_timeline(last_post_id) {
let data = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
data = await instance.#get_timeline_iceshrimp(last_post_id);
break;
case Instance.types.MASTODON:
data = await instance.#get_timeline_mastodon(last_post_id);
break;
default:
console.error(ERR_UNSUPPORTED);
return null;
}
if (data.constructor != Array) {
console.error(ERR_SERVER_RESPONSE);
return null;
}
let posts = [];
data.forEach(post_data => {
const post = Post.parse(post_data);
if (!post) return;
posts = [...posts, post];
});
return posts;
}
async #get_timeline_iceshrimp(last_post_id) {
let body = Object;
if (last_post_id) body.untilId = last_post_id;
const res = await fetch(this.#proto + this.host + "/api/notes/timeline", {
method: 'POST',
headers: { "Authorization": "Bearer " + this.token },
body: JSON.stringify(body)
});
return await res.json();
}
async #get_timeline_mastodon(last_post_id) {
let url = this.#proto + this.host + "/api/v1/timelines/home";
if (last_post_id) url += "?max_id=" + last_post_id;
const res = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + this.token }
});
return await res.json();
}
get #proto() {
if (this.secure) return "https://";
return "http://";
}
static get ok() {
if (!instance) return false;
if (!instance.host) return false;
if (!instance.token) return false;
return true;
}
}

9
src/main.js Normal file
View file

@ -0,0 +1,9 @@
import './app.css';
import App from './App.svelte';
import Instance from './instance.js';
const app = new App({
target: document.getElementById('app')
});
export default app;

166
src/post/Body.svelte Normal file
View file

@ -0,0 +1,166 @@
<script>
export let post;
</script>
<div class="post-body">
{#if post.warning}
<p class="post-warning"><strong>{post.warning}</strong></p>
{/if}
{#if post.text}
<span class="post-text">{@html post.rich_text}</span>
{/if}
<div class="post-media-container" data-count={post.files.length}>
{#each post.files as file}
<div class="post-media image">
<a href={file.url}>
<img src={file.url} alt={file.alt} height="200" loading="lazy" decoding="async">
</a>
</div>
{/each}
</div>
{#if post.boost && post.text}
<p class="post-warning"><strong>this is quoting a post! quotes are not supported yet.</strong></p>
<!-- TODO: quotes support -->
{/if}
</div>
<style>
.post-body {
margin-top: 8px;
}
.post-warning {
padding: 4px 8px;
--warn-bg: rgba(255,220,30,.2);
background-image: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
border-radius: 8px;
}
.post-text {
word-wrap: break-word;
}
.post-text :global(code) {
font-size: 1.2em;
}
.post-text :global(code:has(pre)) {
margin: 8px 0;
padding: 8px;
display: block;
overflow-x: scroll;
border-radius: 8px;
background-color: #080808;
color: var(--accent);
}
.post-text :global(code pre) {
margin: 0;
}
.post-text :global(a) {
color: var(--accent);
}
.post-text :global(a.mention) {
color: var(--accent);
padding: 6px 6px;
margin: -6px 0;
background: var(--accent-bg);
border-radius: 6px;
text-decoration: none;
}
.post-text :global(a.mention:hover) {
text-decoration: underline;
}
.post-text :global(a.hashtag) {
background-color: transparent;
padding: 0;
font-style: italic;
}
.post-text :global(.mention-avatar) {
position: relative;
top: 4px;
height: 20px;
margin-right: 4px;
border-radius: 4px;
}
.post-media-container {
max-height: 540px;
margin-top: 8px;
display: grid;
grid-gap: 8px;
}
.post-media-container[data-count="1"] {
grid-template-rows: 1fr;
}
.post-media-container[data-count="2"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
.post-media-container[data-count="3"] {
grid-template-columns: 1fr .5fr;
grid-template-rows: 1fr 1fr;
}
.post-media-container[data-count="4"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.post-media {
border-radius: 12px;
background-color: #000;
overflow: hidden;
}
.post-media a {
width: 100%;
height: 100%;
display: block;
cursor: zoom-in;
}
.post-media a img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.post-media-container > :nth-child(1) {
grid-column: 1/2;
grid-row: 1/2;
}
.post-media-container[data-count="3"] > :nth-child(1) {
grid-row: 1/3;
}
.post-media-container > :nth-child(2) {
grid-column: 2/2;
grid-row: 1/2;
}
.post-media-container > :nth-child(3) {
grid-column: 1/2;
grid-row: 2/2;
}
.post-media-container[data-count="3"] > :nth-child(3) {
grid-column: 2/2;
grid-row: 2/2;
}
.post-media-container > :nth-child(4) {
grid-column: 2/2;
grid-row: 2/2;
}
</style>

View file

@ -0,0 +1,52 @@
<script>
import { parse_text as parse_emojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<div class="post-context">
<span class="post-context-icon">🔁</span>
<span class="post-context-action">
<a href="/{post.user.mention}">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a> boosted this post.
</span>
<span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time>
</span>
</div>
<style>
.post-context {
margin-bottom: 8px;
padding-left: 58px;
display: flex;
flex-direction: row;
align-items: center;
color: var(--accent);
opacity: .8;
transition: opacity .1s;
}
.post-container:hover .post-context {
opacity: 1;
}
.post-context-icon {
margin-right: 4px;
}
.post-context a,
.post-context a:visited {
color: inherit;
text-decoration: none;
}
.post-context a:hover {
text-decoration: underline;
}
.post-context-time {
margin-left: auto;
}
</style>

View file

@ -0,0 +1,54 @@
<script>
import { play_sound } from '../sound.js';
export let icon = "🔧";
export let type = "action";
export let label = "Action";
export let title = label;
export let count = 0;
export let sound = "default";
</script>
<button
type="button"
class="{type}"
aria-label="{label}"
title="{title}"
on:click={() => (play_sound(sound))}>
<span>{@html icon}</span>
{#if count}
<span class="count">{count}</span>
{/if}
</button>
<style>
button {
padding: 6px 8px;
font-size: 1em;
background: none;
color: inherit;
border: none;
border-radius: 8px;
}
button.active {
background: var(--accent);
color: var(--bg0);
}
button:hover {
background: #8881;
}
button:active {
background: #0001;
}
.count {
opacity: .5;
}
button:hover .count {
opacity: 1;
}
</style>

79
src/post/Header.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import { parse_text as parse_emojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<div class="post-header-container">
<a href="/{post.user.mention}" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<header class="post-header">
<div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
<a href={post.url} class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
</a>
</div>
</header>
</div>
<style>
.post-header-container {
display: flex;
flex-direction: row;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-header-container a:hover {
text-decoration: underline;
}
.post-avatar-container {
margin-right: 12px;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info a {
display: block;
}
.post-user-info .name :global(.emoji) {
position: relative;
top: 4px;
height: 26px;
}
.post-user-info .username {
opacity: .5;
font-size: .9em;
}
.post-info .created-at {
font-size: .8em;
}
</style>

79
src/post/Post.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte';
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import { parse_one as parse_reaction } from '../emoji.js';
import { play_sound } from '../sound.js';
export let post;
let post_context = undefined;
let _post = post;
let is_boost = false;
if (_post.boost) {
is_boost = true;
post_context = _post;
_post = _post.boost;
}
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script>
<div class="post-container" aria-label={aria_label}>
{#if _post.reply}
<ReplyContext post={_post.reply} />
{/if}
{#if is_boost && !post_context.text}
<BoostContext post={post_context} />
{/if}
<article class="post">
<Header post={_post} />
<Body post={_post} />
<footer class="post-footer">
<div class="post-reactions">
{#each Object.keys(_post.reactions) as reaction}
<FooterButton icon={parse_reaction(reaction, _post.emojis)} type="reaction" bind:count={_post.reactions[reaction]} title={reaction} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={_post.reply_count} sound="post" />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={_post.boost_count} sound="boost" />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</article>
</div>
<style>
.post-container {
margin-top: 16px;
padding: 28px 32px 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
transition: background-color .1s;
}
.post-container:hover {
background-color: var(--bg2);
}
.post-reactions {
margin-top: 8px;
}
.post-actions {
margin-top: 8px;
}
.post-container :global(.emoji) {
position: relative;
top: 6px;
height: 26px;
}
</style>

View file

@ -0,0 +1,130 @@
<script>
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import Post from './Post.svelte';
import { parse_text as parse_emojis, parse_one as parse_reaction } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<article class="post-reply">
<div class="post-reply-avatar-container">
<a href="/{post.user.mention}" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<div class="line">
</div>
</div>
<div class="post-reply-main">
<div class="post-header-container">
<header class="post-header">
<div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
<a href={post.url} class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
</a>
</div>
</header>
</div>
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each Object.keys(post.reactions) as reaction}
<FooterButton icon={parse_reaction(reaction, post.emojis)} type="reaction" bind:count={post.reactions[reaction]} title={reaction} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</div>
</article>
<style>
.post-reply {
padding-bottom: 24px;
display: flex;
flex-direction: row;
}
.post-reply-avatar-container {
margin-right: 12px;
margin-bottom: -24px;
}
.post-reply-avatar-container .line {
position: relative;
top: -4px;
left: -1px;
width: 50%;
height: calc(100% - 48px);
border-right: 2px solid #8888;
}
.post-reply-main {
flex-grow: 1;
}
.post-header-container {
display: flex;
flex-direction: row;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-header-container a:hover {
text-decoration: underline;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info a {
display: block;
}
.post-user-info .name :global(.emoji) {
position: relative;
top: 4px;
max-height: 1.25em;
}
.post-user-info .username {
opacity: .5;
font-size: .9em;
}
.post-info .created-at {
font-size: .8em;
}
</style>

220
src/post/post.js Normal file
View file

@ -0,0 +1,220 @@
import Instance from '../instance.js';
import User from '../user/user.js';
import { parse_one as parse_emoji } from '../emoji.js';
let post_cache = Object;
export default class Post {
id;
created_at;
user;
text;
warning;
boost_count;
reply_count;
mentions;
reactions;
emojis;
files;
url;
reply;
boost;
static resolve_id(id) {
return post_cache[id] || null;
}
static parse(data) {
const instance = Instance.get_instance();
let post = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
post = Post.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
post = Post.#parse_mastodon(data);
break;
default:
break;
}
if (!post) {
console.error("Error while parsing post data");
return null;
}
post_cache[post.id] = post;
return post;
}
static #parse_iceshrimp(data) {
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.createdAt);
post.user = User.parse(data.user);
post.text = data.text;
post.warning = data.cw;
post.boost_count = data.renoteCount;
post.reply_count = data.repliesCount;
post.mentions = data.mentions;
post.reactions = data.reactions;
post.emojis = data.emojis;
post.files = data.files;
post.url = data.url;
post.boost = data.renote ? Post.parse(data.renote) : null;
post.reply = data.reply ? Post.parse(data.reply) : null;
return post;
}
static #parse_mastodon(data) {
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.created_at);
post.user = User.parse(data.account);
post.text = data.content;
post.warning = data.spoiler_text;
post.boost_count = data.reblogs_count;
post.reply_count = data.replies_count;
post.mentions = data.mentions;
post.reactions = data.reactions;
post.emojis = data.emojis;
post.files = data.media_attachments;
post.url = data.url;
post.boost = data.reblog ? Post.parse(data.reblog) : null;
post.reply = data.in_reply_to_id ? Post.resolve_id(data.in_reply_to_id) : null;
return post;
}
get rich_text() {
let text = this.text;
if (!text) return text;
const markdown_tokens = [
{ tag: "pre", token: "```" },
{ tag: "code", token: "`" },
{ tag: "strong", token: "**", regex: /\*{2}/g },
{ tag: "strong", token: "__" },
{ tag: "em", token: "*", regex: /\*/g },
{ tag: "em", token: "_" },
];
let response = "";
let current;
let index = 0;
while (index < text.length) {
let sample = text.substring(index);
let allow_new = !current || !current.nostack;
// handle newlines
if (allow_new && sample.startsWith('\n')) {
response += "<br>";
index++;
continue;
}
// handle mentions
if (allow_new && sample.match(/@[a-z0-9-_.]+@[a-z0-9-_.]+/g)) {
// find end of the mention
let length = 1;
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
length++; // skim the middle @
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
let mention = text.substring(index, index + length);
// attempt to resolve mention to a user
let user = User.resolve_mention(mention);
if (user) {
const out = `<a href="/${user.mention}" class="mention">` +
`<img src="${user.avatar_url}" class="mention-avatar" width="20" height="20">` +
"@" + user.name + "</a>";
if (current) current.text += out;
else response += out;
} else {
response += mention;
}
index += mention.length;
continue;
}
if (Instance.get_instance().type !== Instance.types.MASTODON) {
// handle links
if (allow_new && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)) {
// get length of link
let length = text.substring(index).search(/\s|$/g);
let url = text.substring(index, index + length);
let out = `<a href="${url}">${url}</a>`;
if (current) current.text += out;
else response += out;
index += length;
continue;
}
}
// handle emojis
if (allow_new && sample.startsWith(':')) {
// lookahead to next invalid emoji character
let look = sample.substring(1).search(/[^a-zA-Z0-9-_.]/g) + 1;
// if it's ':', we can parse it
if (look !== 0 && sample[look] === ':') {
let emoji_code = sample.substring(0, look + 1);
let out = parse_emoji(emoji_code, this.emojis);
if (current) current.text += out;
else response += out;
index += emoji_code.length;
continue;
}
}
// handle markdown
// TODO: handle misskey-flavoured markdown
if (current) {
// try to pop stack
if (sample.startsWith(current.token)) {
index += current.token.length;
let out = `<${current.tag}>${current.text}</${current.tag}>`;
if (current.token === '```')
out = `<code><pre>${current.text}</pre></code>`;
if (current.parent) current.parent.text += out;
else response += out;
current = current.parent;
} else {
current.text += sample[0];
index++;
}
} else if (allow_new) {
// can we add to stack?
let pushed = false;
for (let i = 0; i < markdown_tokens.length; i++) {
let item = markdown_tokens[i];
if (sample.startsWith(item.token)) {
let new_current = {
token: item.token,
tag: item.tag,
text: "",
parent: current,
};
if (item.token === '```' || item.token === '`') new_current.nostack = true;
current = new_current;
pushed = true;
index += current.token.length;
break;
}
}
if (!pushed) {
response += sample[0];
index++;
}
}
}
// destroy the remaining stack
while (current) {
let out = current.token + current.text;
if (current.parent) current.parent.text += out;
else response += out;
current = current.parent;
}
return response;
}
}

17
src/sound.js Normal file
View file

@ -0,0 +1,17 @@
const sounds = {
"default": new Audio("sound/log.ogg"),
"post": new Audio("sound/success.ogg"),
"boost": new Audio("sound/hello.ogg"),
};
export function play_sound(name) {
if (!name) name = "default";
const sound = sounds[name];
if (!sound) {
console.warn(`Attempted to play sound "${name}", which does not exist!`);
return;
}
sound.pause();
sound.currentTime = 0;
sound.play();
}

23
src/time.js Normal file
View file

@ -0,0 +1,23 @@
const denoms = [
{ unit: 's', min: 0 },
{ unit: 'm', min: 60 },
{ unit: 'h', min: 60 },
{ unit: 'd', min: 24 },
{ unit: 'w', min: 7 },
{ unit: 'y', min: 52 },
];
export function shorthand(date) {
let value = (new Date() - date) / 1000;
let unit = 's';
let index = 0;
while (index < denoms.length - 1) {
if (value < denoms[index + 1].min) break;
index++
value /= denoms[index].min;
unit = denoms[index].unit;
}
if (value > 0)
return Math.floor(value) + unit + " ago";
return "in " + Math.floor(value) + unit;
}

83
src/user/user.js Normal file
View file

@ -0,0 +1,83 @@
import Instance from '../instance.js';
import Emoji from '../emoji.js';
let user_cache = Object;
export default class User {
id;
nickname;
username;
host;
avatar_url;
emojis;
static resolve_id(id) {
return user_cache[id];
}
static resolve_mention(mention) {
for (let i = 0; i < Object.keys(user_cache).length; i++) {
let user = user_cache[Object.keys(user_cache)[i]];
if (user.mention === mention) return user;
}
}
static parse(data) {
const instance = Instance.get_instance();
let user = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
user = User.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
user = User.#parse_mastodon(data);
break;
default:
break;
}
if (!user) {
console.error("Error while parsing user data");
return null;
}
user_cache[user.id] = user;
return user;
}
static #parse_iceshrimp(data) {
let user = new User();
user.id = data.id;
user.nickname = data.name;
user.username = data.username;
user.host = data.host || Instance.get_instance().host;
user.avatar_url = data.avatarUrl;
user.emojis = [];
data.emojis.forEach(emoji => {
user.emojis.push(Emoji.parse(emoji, user.host));
});
return user;
}
static #parse_mastodon(data) {
let user = new User();
user.id = data.id;
user.nickname = data.display_name;
user.username = data.username;
user.host = data.acct.search('@') ? data.acct.substring(data.acct.search('@') + 1) : instance.host;
user.avatar_url = data.avatar;
user.emojis = [];
data.emojis.forEach(emoji => {
user.emojis.push(Emoji.parse(emoji, user.host));
});
return user;
}
get name() {
return this.nickname || this.username;
}
get mention() {
let res = "@" + this.username;
if (this.host) res += "@" + this.host;
return res;
}
}