wumbo changes (proper mastodon API support and oauth login!)

This commit is contained in:
ari melody 2024-06-19 22:13:16 +01:00
parent b7c03381f7
commit e17b26b075
Signed by: ari
GPG key ID: CF99829C92678188
20 changed files with 1935 additions and 1618 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
**/.DS_Store **/.DS_Store
node_modules/ node_modules/
dist/ dist/
# .secret/

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "spacesocial-client", "name": "spacesocial-client",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "spacesocial-client", "name": "spacesocial-client",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^3.1.1",

View file

@ -4,7 +4,7 @@
"description": "social media for the galaxy-wide-web! 🌌", "description": "social media for the galaxy-wide-web! 🌌",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },

View file

@ -1,84 +1,72 @@
<script> <script>
import Feed from './Feed.svelte'; import Feed from './Feed.svelte';
import Error from './Error.svelte'; import { Client, server_types } from './client/client.js';
import Instance from './instance.js';
let ready = false; let ready = Client.get().app && Client.get().app.token;
if (localStorage.getItem("fedi_host") && localStorage.getItem("fedi_token")) {
Instance.setup( let auth_code = new URLSearchParams(location.search).get("code");
localStorage.getItem("fedi_host"), if (auth_code) {
localStorage.getItem("fedi_token"), let client = Client.get();
true client.getToken(auth_code).then(() => {
).then(() => { client.save();
ready = true; location = location.origin;
}); });
} }
function log_in(event) { function log_in(event) {
let client = Client.get();
event.preventDefault(); event.preventDefault();
localStorage.setItem("fedi_host", event.target.instance_host.value); const host = event.target.host.value;
localStorage.setItem("fedi_token", event.target.session_token.value);
location = location; client.init(host).then(() => {
if (client.instance.type === server_types.INCOMPATIBLE) {
console.error("Server " + client.instance.host + " is not supported - " + client.instance.version);
alert("Sorry, this app is not compatible with " + client.instance.host + "!");
return;
}
console.log("Server is \"" + client.instance.type + "\" (or compatible).");
client.save();
let oauth_url = client.getOAuthUrl();
location = oauth_url;
});
} }
function log_out() { function log_out() {
localStorage.removeItem("fedi_host"); Client.get().logout().then(() => {
localStorage.removeItem("fedi_token"); ready = false;
location = location; });
} }
</script> </script>
<header> <header>
<h1>space social</h1> <h1>space social</h1>
<p>social media for the galaxy-wide-web! 🌌</p> <p>social media for the galaxy-wide-web! 🌌</p>
<button id="log-out" on:click={log_out}>log out</button> <button id="logout" on:click={log_out}>log out</button>
</header> </header>
<main> <main>
{#if ready} {#if ready}
<Feed /> <Feed />
{:else if !Instance.get_instance().ok} {:else}
<Error> <div class="pane">
<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={log_in} id="login">
<h1>welcome!</h1>
<form on:submit={data => (log_in(data))}> <p>please enter your instance domain to log in.</p>
<label for="instance host">instance host: </label> <input type="text" id="host" aria-label="instance domain">
<input type="text" id="instance_host"> <button type="submit" id="login">log in</button>
<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> </form>
<hr> <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> <p><small>
your login credentials will not be saved to an external server. please note this is <strong><em>extremely experimental software</em></strong>;
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. even if you use the exact same instance as me, you may encounter problems.
if that's all cool with you, welcome aboard! if that's all cool with you, welcome aboard!
</small></p> </small></p>
<p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p> <p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</Error> </div>
{/if} {/if}
</main> </main>
@ -95,9 +83,12 @@
align-items: center; align-items: center;
} }
header h1 {
margin: 0 16px 0 0;
}
h1 { h1 {
color: var(--accent); color: var(--accent);
margin: 0 16px 0 0;
} }
main { main {
@ -105,6 +96,19 @@
margin: 0 auto; margin: 0 auto;
} }
div.pane {
margin-top: 16px;
padding: 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
}
form#login {
margin: 64px 0;
text-align: center;
}
a { a {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
@ -115,14 +119,14 @@
} }
input[type="text"], input[type="password"] { input[type="text"], input[type="password"] {
margin-bottom: 8px; margin: 8px 0;
padding: 4px 6px; padding: 4px 6px;
font-family: inherit; font-family: inherit;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
} }
button#log-in, button#log-out { button#login, button#logout {
margin-left: auto; margin-left: auto;
padding: 8px 12px; padding: 8px 12px;
font-size: 1em; font-size: 1em;
@ -134,17 +138,12 @@
transition: color .1s, background-color .1s; transition: color .1s, background-color .1s;
} }
button#log-in.active, button#log-out.active { button#login:hover, button#logout:hover {
background: var(--accent);
color: var(--bg0);
}
button#log-in:hover, button#log-out:hover {
color: var(--bg0); color: var(--bg0);
background: var(--fg0); background: var(--fg0);
} }
button#log-in:active, button#log-out:active { button#login:active, button#logout:active {
background: #0001; background: #0001;
} }

View file

@ -21,6 +21,4 @@
border-radius: 16px; border-radius: 16px;
background-color: var(--bg1); background-color: var(--bg1);
} }
</style> </style>

View file

@ -1,8 +1,9 @@
<script> <script>
import Post from './post/Post.svelte'; import Post from './post/Post.svelte';
import Error from './Error.svelte'; import Error from './Error.svelte';
import Instance from './instance.js'; import { Client } from './client/client.js';
let client = Client.get();
let posts = []; let posts = [];
let loading = false; let loading = false;
@ -13,15 +14,11 @@
loading = true; loading = true;
let new_posts = []; let new_posts = [];
if (posts.length === 0) new_posts = await Instance.get_timeline() if (posts.length === 0) new_posts = await client.getTimeline()
else new_posts = await Instance.get_timeline(posts[posts.length - 1].id); else new_posts = await client.getTimeline(posts[posts.length - 1].id);
if (!new_posts) { if (!new_posts) {
error = `sorry! the frontend is unable to communicate with your server. console.error(`Failed to retrieve timeline posts.`);
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; loading = false;
return; return;
} }
@ -37,13 +34,53 @@ for more information, please consult the developer console.`;
load_posts(); load_posts();
} }
}); });
/*
client.getPost("9upf5wtam363h1tp", 1).then(post => {
posts = [...posts, post];
console.log(post);
});
*/
</script> </script>
<div id="feed"> <div id="feed">
{#if error} {#if error}
<Error msg={error.replaceAll('\n', '<br>')} /> <Error msg={error.replaceAll('\n', '<br>')} />
{/if} {/if}
{#if posts.length <= 0}
<div class="loading">
<span>just a moment...</span>
</div>
{/if}
{#each posts as post} {#each posts as post}
<Post post={post} /> <Post post_data={post} />
{/each} {/each}
</div> </div>
<style>
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
.loading span {
animation: pulse 1s infinite;
}
@keyframes pulse {
from {
opacity: .5;
}
50% {
opacity: 1;
}
to {
opacity: .5;
}
}
</style>

112
src/api/firefish.js Normal file
View file

@ -0,0 +1,112 @@
import { Client } from '../client/client.js';
import Post from '../post/post.js';
import User from '../user/user.js';
import Emoji from '../emoji.js';
import * as mastodonAPI from './mastodon.js';
export async function createApp(host) {
return await mastodonAPI.createApp(host);
}
export function getOAuthUrl() {
return mastodonAPI.getOAuthUrl();
}
export async function getToken(code) {
return await mastodonAPI.getToken(code);
}
export async function revokeToken() {
return await mastodonAPI.revokeToken();
}
export async function getTimeline(last_post_id) {
return await mastodonAPI.getTimeline(last_post_id);
}
export async function getPost(post_id, num_replies) {
return await mastodonAPI.getPost(post_id, num_replies);
}
export async function parsePost(data, num_replies) {
let client = Client.get();
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.created_at);
post.user = await Client.get().api.parseUser(data.account);
post.text = data.text;
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.files = data.media_attachments;
post.url = data.url;
post.reply = data.in_reply_to_id && num_replies > 0 ? await getPost(data.in_reply_to_id, num_replies - 1) : null;
post.boost = data.reblog ? await Client.get().api.parsePost(data.reblog, 1) : null;
post.emojis = [];
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(Client.get().api.parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
post.reactions = [];
data.reactions.forEach(reaction_data => {
if (/^[\w\-.@]+$/g.exec(reaction_data.name)) {
let name = reaction_data.name.split('@')[0];
let host = reaction_data.name.includes('@') ? reaction_data.name.split('@')[1] : client.instance.host;
post.reactions.push({
count: reaction_data.count,
emoji: Client.get().api.parseEmoji({
id: name + '@' + host,
name: name,
host: host,
url: reaction_data.url,
}),
me: reaction_data.me,
});
} else {
if (reaction_data.name == '❤') reaction_data.name = '❤️'; // stupid heart unicode
post.reactions.push({
count: reaction_data.count,
emoji: {
html: reaction_data.name,
name: reaction_data.name,
},
me: reaction_data.me,
});
}
});
return post;
}
export async function parseUser(data) {
let user = new User();
user.id = data.id;
user.nickname = data.display_name;
user.username = data.username;
user.host = data.fqn.split('@')[1];
user.avatar_url = data.avatar;
user.emojis = [];
data.emojis.forEach(emoji_data => {
emoji_data.id = emoji_data.shortcode + '@' + user.host;
emoji_data.name = emoji_data.shortcode;
emoji_data.host = user.host;
user.emojis.push(Client.get().api.parseEmoji(emoji_data));
});
Client.get().putCacheUser(user);
return user;
}
export function parseEmoji(data) {
return mastodonAPI.parseEmoji(data);
}
export async function getUser(user_id) {
return mastodonAPI.getUser(user_id);
}

240
src/api/mastodon.js Normal file
View file

@ -0,0 +1,240 @@
import { Client } from '../client/client.js';
import Post from '../post/post.js';
import User from '../user/user.js';
import Emoji from '../emoji.js';
export async function createApp(host) {
let form = new FormData();
form.append("client_name", "space social");
form.append("redirect_uris", `${location.origin}/callback`);
form.append("scopes", "read write push");
form.append("website", "https://spacesocial.arimelody.me");
const res = await fetch(`https://${host}/api/v1/apps`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.client_id) return false;
return {
id: res.client_id,
secret: res.client_secret,
};
}
export function getOAuthUrl() {
let client = Client.get();
return `https://${client.instance.host}/oauth/authorize` +
`?client_id=${client.app.id}` +
"&scope=read+write+push" +
`&redirect_uri=${location.origin}/callback` +
"&response_type=code";
}
export async function getToken(code) {
let client = Client.get();
let form = new FormData();
form.append("client_id", client.app.id);
form.append("client_secret", client.app.secret);
form.append("redirect_uri", `${location.origin}/callback`);
form.append("grant_type", "authorization_code");
form.append("code", code);
form.append("scope", "read write push");
const res = await fetch(`https://${client.instance.host}/oauth/token`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.access_token) return false;
return res.access_token;
}
export async function revokeToken() {
let client = Client.get();
let form = new FormData();
form.append("client_id", client.app.id);
form.append("client_secret", client.app.secret);
form.append("token", client.app.token);
const res = await fetch(`https://${client.instance.host}/oauth/revoke`, {
method: "POST",
body: form,
})
.catch(error => {
console.error(error);
return false;
});
if (!res.ok) return false;
return true;
}
export async function getTimeline(last_post_id) {
let client = Client.get();
let url = `https://${client.instance.host}/api/v1/timelines/home`;
if (last_post_id) url += "?max_id=" + last_post_id;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json());
let posts = [];
for (let i in data) {
const post_data = data[i];
const post = await client.api.parsePost(post_data, 1);
if (!post) {
if (post_data.id) {
console.warn("Failed to parse post #" + post_data.id);
} else {
console.warn("Failed to parse post:");
console.warn(post_data);
}
continue;
}
posts.push(post);
}
return posts;
}
export async function getPost(post_id, num_replies) {
let client = Client.get();
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json());
const post = await client.api.parsePost(data, num_replies);
if (!post) {
if (data.id) {
console.warn("Failed to parse post data #" + data.id);
} else {
console.warn("Failed to parse post data:");
console.warn(data);
}
return false;
}
return post;
}
export async function parsePost(data, num_replies) {
let client = Client.get();
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.created_at);
post.user = await Client.get().api.parseUser(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.files = data.media_attachments;
post.url = data.url;
post.reply = data.in_reply_to_id && num_replies > 0 ? await getPost(data.in_reply_to_id, num_replies - 1) : null;
post.boost = data.reblog ? await Client.get().api.parsePost(data.reblog, 1) : null;
post.emojis = [];
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(Client.get().api.parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
post.reactions = [];
data.reactions.forEach(reaction_data => {
if (/^[\w\-.@]+$/g.exec(reaction_data.name)) {
let name = reaction_data.name.split('@')[0];
let host = reaction_data.name.includes('@') ? reaction_data.name.split('@')[1] : client.instance.host;
post.reactions.push({
count: reaction_data.count,
emoji: Client.get().api.parseEmoji({
id: name + '@' + host,
name: name,
host: host,
url: reaction_data.url,
}),
me: reaction_data.me,
});
} else {
if (reaction_data.name == '❤') reaction_data.name = '❤️'; // stupid heart unicode
post.reactions.push({
count: reaction_data.count,
emoji: {
html: reaction_data.name,
name: reaction_data.name,
},
me: reaction_data.me,
});
}
});
return post;
}
export async function parseUser(data) {
let user = new User();
user.id = data.id;
user.nickname = data.display_name;
user.username = data.username;
if (data.acct.includes('@'))
user.host = data.acct.split('@')[1];
else
user.host = data.username + '@' + Client.get().instance.host;
user.avatar_url = data.avatar;
user.emojis = [];
data.emojis.forEach(emoji_data => {
emoji_data.id = emoji_data.shortcode + '@' + user.host;
emoji_data.name = emoji_data.shortcode;
emoji_data.host = user.host;
user.emojis.push(Client.get().api.parseEmoji(emoji_data));
});
Client.get().putCacheUser(user);
return user;
}
export function parseEmoji(data) {
let emoji = new Emoji(
data.id,
data.name,
data.host,
data.url,
);
Client.get().putCacheEmoji(emoji);
return emoji;
}
export async function getUser(user_id) {
let client = Client.get();
let url = `https://${client.instance.host}/api/v1/accounts/${user_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json());
const user = await Client.get().api.parseUser(data);
if (!post) {
if (data.id) {
console.warn("Failed to parse user data #" + data.id);
} else {
console.warn("Failed to parse user data:");
console.warn(data);
}
return false;
}
return user;
}

176
src/client/client.js Normal file
View file

@ -0,0 +1,176 @@
import * as mastodonAPI from '../api/mastodon.js';
import * as firefishAPI from '../api/firefish.js';
import * as misskeyAPI from '../api/misskey.js';
let client = false;
export const server_types = {
INCOMPATIBLE: "incompatible",
MASTODON: "mastodon",
FIREFISH: "firefish",
};
const save_name = "spacesocial";
const versions_types = [
{ version: "mastodon", type: server_types.MASTODON },
{ version: "glitchsoc", type: server_types.MASTODON },
{ version: "chuckya", type: server_types.MASTODON },
{ version: "iceshrimp", type: server_types.FIREFISH },
{ version: "sharkey", type: server_types.FIREFISH },
];
export class Client {
#api;
instance;
app;
#cache;
constructor() {
this.instance = null;
this.app = null;
this.cache = {
users: {},
emojis: {},
};
}
static get() {
if (client) return client;
client = new Client();
window.peekie = client;
client.load();
if (client.instance) client.#configureAPI();
return client;
}
async init(host) {
if (host.startsWith("https://")) host = host.substring(8);
const url = "https://" + host + "/api/v1/instance";
const data = await fetch(url).then(res => res.json());
this.instance = {
host: host,
version: data.version,
type: server_types.INCOMPATIBLE,
};
for (let index in versions_types) {
const pair = versions_types[index];
if (data.version.toLowerCase().includes(pair.version)) {
this.instance.type = pair.type;
break;
}
}
this.#configureAPI();
this.app = await this.api.createApp(host);
if (!this.app || !this.instance) {
console.error("Failed to create app. Check the network logs for details.");
return false;
}
return this.auth;
}
#configureAPI() {
switch (this.instance.type) {
case server_types.MASTODON:
this.api = mastodonAPI;
break;
case server_types.FIREFISH:
this.api = firefishAPI;
break;
/* not opening that can of worms for a while
case server_types.MISSKEY:
this.api = misskeyAPI;
break; */
default:
break;
}
}
getOAuthUrl() {
return this.api.getOAuthUrl(this.app.secret);
}
async getToken(code) {
const token = await this.api.getToken(code);
if (!token) {
console.error("Failed to obtain access token");
return false;
}
this.app.token = token;
}
async revokeToken() {
return await this.api.revokeToken();
}
async getTimeline(last_post_id) {
return await this.api.getTimeline(last_post_id);
}
async getPost(post_id, num_replies) {
return await this.api.getPost(post_id, num_replies);
}
putCacheUser(user) {
this.cache.users[user.id] = user;
}
async getUser(user_id) {
let user = this.cache.users[user_id];
if (user) return user;
user = await this.api.getUser(user_id);
if (user) return user;
return false;
}
async getUserByMention(mention) {
let users = Object.values(this.cache.users);
for (let i in users) {
const user = users[i];
if (user.mention == mention) return user;
}
return false;
}
putCacheEmoji(emoji) {
this.cache.emojis[emoji.id] = emoji;
}
getEmoji(emoji_id) {
let emoji = this.cache.emojis[emoji_id];
if (!emoji) return false;
return emoji;
}
save() {
localStorage.setItem(save_name, JSON.stringify({
instance: this.instance,
app: this.app,
}));
}
load() {
let json = localStorage.getItem(save_name);
if (!json) return false;
let saved = JSON.parse(json);
this.instance = saved.instance;
this.app = saved.app;
return true;
}
async logout() {
if (!this.instance || !this.app || !this.api) return;
if (!await this.revokeToken()) {
console.warn("Failed to log out correctly; ditching the old tokens anyways.");
}
localStorage.removeItem(save_name);
client = new Client();
console.log("Logged out successfully.");
}
}

View file

@ -1,93 +1,50 @@
import Instance from './instance.js'; import { Client } from './client/client.js';
const EMOJI_REGEX = /:[a-z0-9_\-]+:/g; export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g;
export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g;
let emoji_cache = [];
export default class Emoji { export default class Emoji {
id;
name; name;
host; host;
url; url;
width;
height;
static parse(data, host) { constructor(id, name, host, url) {
const instance = Instance.get_instance(); this.id = id;
let emoji = null; this.name = name;
switch (instance.type) { this.host = host;
case Instance.types.ICESHRIMP: this.url = url;
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) { get html() {
let emoji = new Emoji() return `<img src="${this.url}" class="emoji" height="20" title="${this.name}" alt="${this.name}">`;
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) { export function parseText(text, host) {
if (!text) return text; if (!text) return text;
let index = text.search(EMOJI_REGEX); let index = text.search(EMOJI_NAME_REGEX);
if (index === -1) return text; if (index === -1) return text;
index++;
// find the emoji name // find the emoji name
let length = 0; let length = text.substring(index + 1).search(':');
while (index + length < text.length && text[index + length] !== ':') length++; if (length <= 0) return text;
let emoji_name = ':' + text.substring(index, index + length) + ':'; let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = Client.get().getEmoji(emoji_name + '@' + host);
// does this emoji exist? if (emoji) {
let emoji; return text.substring(0, index) + emoji.html +
for (let cached in emoji_cache) { parseText(text.substring(index + length + 2), host);
if (cached.id === emoji_name) {
emoji = cached;
break;
} }
} return text.substring(0, index + length + 1) +
parseText(text.substring(index + length + 1), host);
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) { export function parseOne(emoji_id) {
if (reaction == '❤') return '❤️'; // stupid heart unicode if (emoji_id == '❤') return '❤️'; // stupid heart unicode
if (!reaction.startsWith(':') || !reaction.endsWith(':')) return reaction; if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id;
for (let i = 0; i < emojis.length; i++) { let cached_emoji = Client.get().getEmoji(emoji_id);
if (emojis[i].name == reaction.substring(1, reaction.length - 1)) if (!cached_emoji) return emoji_id;
return `<img src="${emojis[i].url}" class="emoji" width="26" height="26" title="${reaction}" alt="${emojis[i].name}">`; return cached_emoji.html;
}
return reaction;
} }

View file

@ -1,107 +0,0 @@
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;
}
}

View file

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

View file

@ -1,5 +1,8 @@
<script> <script>
export let post; export let post;
let rich_text;
post.rich_text().then(res => {rich_text = res});
</script> </script>
<div class="post-body"> <div class="post-body">
@ -7,7 +10,7 @@
<p class="post-warning"><strong>{post.warning}</strong></p> <p class="post-warning"><strong>{post.warning}</strong></p>
{/if} {/if}
{#if post.text} {#if post.text}
<span class="post-text">{@html post.rich_text}</span> <span class="post-text">{@html rich_text}</span>
{/if} {/if}
<div class="post-media-container" data-count={post.files.length}> <div class="post-media-container" data-count={post.files.length}>
{#each post.files as file} {#each post.files as file}
@ -40,6 +43,12 @@
word-wrap: break-word; word-wrap: break-word;
} }
.post-text :global(.emoji) {
position: relative;
top: 6px;
height: 24px!important;
}
.post-text :global(code) { .post-text :global(code) {
font-size: 1.2em; font-size: 1.2em;
} }

View file

@ -1,5 +1,5 @@
<script> <script>
import { parse_text as parse_emojis } from '../emoji.js'; import { parseText as parseEmojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js'; import { shorthand as short_time } from '../time.js';
export let post; export let post;
@ -10,7 +10,7 @@
<div class="post-context"> <div class="post-context">
<span class="post-context-icon">🔁</span> <span class="post-context-icon">🔁</span>
<span class="post-context-action"> <span class="post-context-action">
<a href="/{post.user.mention}">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a> boosted this post. <a href="/{post.user.mention}">{@html parseEmojis(post.user.name)}</a> boosted this post.
</span> </span>
<span class="post-context-time"> <span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time> <time title="{time_string}">{short_time(post.created_at)}</time>

View file

@ -15,7 +15,7 @@
aria-label="{label}" aria-label="{label}"
title="{title}" title="{title}"
on:click={() => (play_sound(sound))}> on:click={() => (play_sound(sound))}>
<span>{@html icon}</span> <span class="icon">{@html icon}</span>
{#if count} {#if count}
<span class="count">{count}</span> <span class="count">{count}</span>
{/if} {/if}
@ -23,7 +23,11 @@
<style> <style>
button { button {
height: 32px;
padding: 6px 8px; padding: 6px 8px;
display: flex;
flex-direction: row;
gap: 4px;
font-size: 1em; font-size: 1em;
background: none; background: none;
color: inherit; color: inherit;
@ -44,6 +48,14 @@
background: #0001; background: #0001;
} }
.icon {
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.count { .count {
opacity: .5; opacity: .5;
} }

View file

@ -1,5 +1,5 @@
<script> <script>
import { parse_text as parse_emojis } from '../emoji.js'; import { parseText as parseEmojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js'; import { shorthand as short_time } from '../time.js';
export let post; export let post;
@ -13,7 +13,7 @@
</a> </a>
<header class="post-header"> <header class="post-header">
<div class="post-user-info"> <div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a> <a href="/{post.user.mention}" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span> <span class="username">{post.user.mention}</span>
</div> </div>
<div class="post-info"> <div class="post-info">

View file

@ -4,42 +4,42 @@
import Header from './Header.svelte'; import Header from './Header.svelte';
import Body from './Body.svelte'; import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte'; import FooterButton from './FooterButton.svelte';
import { parse_one as parse_reaction } from '../emoji.js'; import { parseOne as parseEmoji } from '../emoji.js';
import { play_sound } from '../sound.js'; import { play_sound } from '../sound.js';
export let post; export let post_data;
let post_context = undefined; let post_context = undefined;
let _post = post; let post = post_data;
let is_boost = false; let is_boost = false;
if (_post.boost) { if (post_data.boost) {
is_boost = true; is_boost = true;
post_context = _post; post_context = post_data;
_post = _post.boost; post = post_data.boost;
} }
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script> </script>
<div class="post-container" aria-label={aria_label}> <div class="post-container" aria-label={aria_label}>
{#if _post.reply} {#if post.reply}
<ReplyContext post={_post.reply} /> <ReplyContext post={post.reply} />
{/if} {/if}
{#if is_boost && !post_context.text} {#if is_boost && !post_context.text}
<BoostContext post={post_context} /> <BoostContext post={post_context} />
{/if} {/if}
<article class="post"> <article class="post">
<Header post={_post} /> <Header post={post} />
<Body post={_post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions"> <div class="post-reactions">
{#each Object.keys(_post.reactions) as reaction} {#each post.reactions as reaction}
<FooterButton icon={parse_reaction(reaction, _post.emojis)} type="reaction" bind:count={_post.reactions[reaction]} title={reaction} label="" /> <FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each} {/each}
</div> </div>
<div class="post-actions"> <div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={_post.reply_count} sound="post" /> <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="boost" label="Boost" bind:count={post.boost_count} sound="boost" />
<FooterButton icon="⭐" type="favourite" label="Favourite" /> <FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" /> <FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" /> <FooterButton icon="🗣️" type="quote" label="Quote" />
@ -63,17 +63,19 @@
background-color: var(--bg2); background-color: var(--bg2);
} }
.post-reactions { :global(.post-reactions) {
margin-top: 8px; margin-top: 16px;
display: flex;
flex-direction: row;
} }
.post-actions { :global(.post-actions) {
margin-top: 8px; margin-top: 8px;
display: flex;
flex-direction: row;
} }
.post-container :global(.emoji) { .post-container :global(.emoji) {
position: relative; height: 20px;
top: 6px;
height: 26px;
} }
</style> </style>

View file

@ -3,7 +3,7 @@
import Body from './Body.svelte'; import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte'; import FooterButton from './FooterButton.svelte';
import Post from './Post.svelte'; import Post from './Post.svelte';
import { parse_text as parse_emojis, parse_one as parse_reaction } from '../emoji.js'; import { parseText as parseEmojis, parseOne as parseEmoji } from '../emoji.js';
import { shorthand as short_time } from '../time.js'; import { shorthand as short_time } from '../time.js';
export let post; export let post;
@ -24,7 +24,7 @@
<div class="post-header-container"> <div class="post-header-container">
<header class="post-header"> <header class="post-header">
<div class="post-user-info"> <div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a> <a href="/{post.user.mention}" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span> <span class="username">{post.user.mention}</span>
</div> </div>
<div class="post-info"> <div class="post-info">
@ -39,8 +39,8 @@
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions"> <div class="post-reactions">
{#each Object.keys(post.reactions) as reaction} {#each post.reactions as reaction}
<FooterButton icon={parse_reaction(reaction, post.emojis)} type="reaction" bind:count={post.reactions[reaction]} title={reaction} label="" /> <FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each} {/each}
</div> </div>
<div class="post-actions"> <div class="post-actions">

View file

@ -1,9 +1,5 @@
import Instance from '../instance.js'; import { Client, server_types } from '../client/client.js';
import User from '../user/user.js'; import { parseOne as parseEmoji, EMOJI_REGEX } from '../emoji.js';
import { parse_one as parse_emoji } from '../emoji.js';
let post_cache = Object;
export default class Post { export default class Post {
id; id;
@ -21,70 +17,7 @@ export default class Post {
reply; reply;
boost; boost;
static resolve_id(id) { async rich_text() {
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; let text = this.text;
if (!text) return text; if (!text) return text;
@ -112,7 +45,8 @@ export default class Post {
} }
// handle mentions // handle mentions
if (allow_new && sample.match(/@[a-z0-9-_.]+@[a-z0-9-_.]+/g)) { // TODO: setup a better system for handling different server capabilities
if (Client.get().instance.type !== server_types.MASTODON && allow_new && sample.match(/^@[\w\-.]+@[\w\-.]+/g)) {
// find end of the mention // find end of the mention
let length = 1; let length = 1;
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++; while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
@ -122,11 +56,11 @@ export default class Post {
let mention = text.substring(index, index + length); let mention = text.substring(index, index + length);
// attempt to resolve mention to a user // attempt to resolve mention to a user
let user = User.resolve_mention(mention); let user = await Client.get().getUserByMention(mention);
if (user) { if (user) {
const out = `<a href="/${user.mention}" class="mention">` + const out = `<a href="/${user.mention}" class="mention">` +
`<img src="${user.avatar_url}" class="mention-avatar" width="20" height="20">` + `<img src="${user.avatar_url}" class="mention-avatar" width="20" height="20">` +
"@" + user.name + "</a>"; '@' + user.username + '@' + user.host + "</a>";
if (current) current.text += out; if (current) current.text += out;
else response += out; else response += out;
} else { } else {
@ -136,9 +70,8 @@ export default class Post {
continue; continue;
} }
if (Instance.get_instance().type !== Instance.types.MASTODON) {
// handle links // handle links
if (allow_new && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)) { if (Client.get().instance.type !== server_types.MASTODON && allow_new && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)) {
// get length of link // get length of link
let length = text.substring(index).search(/\s|$/g); let length = text.substring(index).search(/\s|$/g);
let url = text.substring(index, index + length); let url = text.substring(index, index + length);
@ -148,21 +81,28 @@ export default class Post {
index += length; index += length;
continue; continue;
} }
}
// handle emojis // handle emojis
if (allow_new && sample.startsWith(':')) { if (allow_new && sample.match(/^:[\w\-.]{0,32}:/g)) {
// lookahead to next invalid emoji character // find the emoji name
let look = sample.substring(1).search(/[^a-zA-Z0-9-_.]/g) + 1; let length = text.substring(index + 1).search(':');
// if it's ':', we can parse it if (length <= 0) return text;
if (look !== 0 && sample[look] === ':') { let emoji_name = text.substring(index + 1, index + length + 1);
let emoji_code = sample.substring(0, look + 1); let emoji = Client.get().getEmoji(emoji_name + '@' + this.user.host);
let out = parse_emoji(emoji_code, this.emojis);
index += length + 2;
if (!emoji) {
let out = ':' + emoji_name + ':';
if (current) current.text += out; if (current) current.text += out;
else response += out; else response += out;
index += emoji_code.length;
continue; continue;
} }
let out = emoji.html;
if (current) current.text += out;
else response += out;
continue;
} }
// handle markdown // handle markdown

View file

@ -1,7 +1,4 @@
import Instance from '../instance.js'; import { parseText as parseEmojis } from '../emoji.js';
import Emoji from '../emoji.js';
let user_cache = Object;
export default class User { export default class User {
id; id;
@ -11,66 +8,6 @@ export default class User {
avatar_url; avatar_url;
emojis; 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() { get name() {
return this.nickname || this.username; return this.nickname || this.username;
} }
@ -80,4 +17,9 @@ export default class User {
if (this.host) res += "@" + this.host; if (this.host) res += "@" + this.host;
return res; return res;
} }
get rich_name() {
if (!this.nickname) return this.username;
return parseEmojis(this.nickname, this.host);
}
} }