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
node_modules/
dist/
# .secret/

4
package-lock.json generated
View file

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

View file

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

View file

@ -1,84 +1,72 @@
<script>
import Feed from './Feed.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
import { Client, server_types } from './client/client.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;
let ready = Client.get().app && Client.get().app.token;
let auth_code = new URLSearchParams(location.search).get("code");
if (auth_code) {
let client = Client.get();
client.getToken(auth_code).then(() => {
client.save();
location = location.origin;
});
}
function log_in(event) {
let client = Client.get();
event.preventDefault();
localStorage.setItem("fedi_host", event.target.instance_host.value);
localStorage.setItem("fedi_token", event.target.session_token.value);
location = location;
const host = event.target.host.value;
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() {
localStorage.removeItem("fedi_host");
localStorage.removeItem("fedi_token");
location = location;
Client.get().logout().then(() => {
ready = false;
});
}
</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>
<button id="logout" 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>
{:else}
<div class="pane">
<form on:submit={log_in} id="login">
<h1>welcome!</h1>
<p>please enter your instance domain to log in.</p>
<input type="text" id="host" aria-label="instance domain">
<button type="submit" id="login">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>;
please note 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>
</div>
{/if}
</main>
@ -95,9 +83,12 @@
align-items: center;
}
header h1 {
margin: 0 16px 0 0;
}
h1 {
color: var(--accent);
margin: 0 16px 0 0;
}
main {
@ -105,6 +96,19 @@
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 {
color: var(--accent);
text-decoration: none;
@ -115,14 +119,14 @@
}
input[type="text"], input[type="password"] {
margin-bottom: 8px;
margin: 8px 0;
padding: 4px 6px;
font-family: inherit;
border: none;
border-radius: 8px;
}
button#log-in, button#log-out {
button#login, button#logout {
margin-left: auto;
padding: 8px 12px;
font-size: 1em;
@ -134,17 +138,12 @@
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 {
button#login:hover, button#logout:hover {
color: var(--bg0);
background: var(--fg0);
}
button#log-in:active, button#log-out:active {
button#login:active, button#logout:active {
background: #0001;
}

View file

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

View file

@ -1,8 +1,9 @@
<script>
import Post from './post/Post.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
import { Client } from './client/client.js';
let client = Client.get();
let posts = [];
let loading = false;
@ -13,15 +14,11 @@
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 (posts.length === 0) new_posts = await client.getTimeline()
else new_posts = await client.getTimeline(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.`;
console.error(`Failed to retrieve timeline posts.`);
loading = false;
return;
}
@ -37,13 +34,53 @@ for more information, please consult the developer console.`;
load_posts();
}
});
/*
client.getPost("9upf5wtam363h1tp", 1).then(post => {
posts = [...posts, post];
console.log(post);
});
*/
</script>
<div id="feed">
{#if error}
<Error msg={error.replaceAll('\n', '<br>')} />
{/if}
{#if posts.length <= 0}
<div class="loading">
<span>just a moment...</span>
</div>
{/if}
{#each posts as post}
<Post post={post} />
<Post post_data={post} />
{/each}
</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;
let emoji_cache = [];
export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g;
export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g;
export default class Emoji {
id;
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;
constructor(id, name, host, url) {
this.id = id;
this.name = name;
this.host = host;
this.url = url;
}
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;
get html() {
return `<img src="${this.url}" class="emoji" height="20" title="${this.name}" alt="${this.name}">`;
}
}
export function parse_text(text, ignore_instance) {
export function parseText(text, host) {
if (!text) return text;
let index = text.search(EMOJI_REGEX);
let index = text.search(EMOJI_NAME_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) + ':';
let length = text.substring(index + 1).search(':');
if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = Client.get().getEmoji(emoji_name + '@' + host);
// 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) + emoji.html +
parseText(text.substring(index + length + 2), 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);
return text.substring(0, index + length + 1) +
parseText(text.substring(index + length + 1), host);
}
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;
export function parseOne(emoji_id) {
if (emoji_id == '❤') return '❤️'; // stupid heart unicode
if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id;
let cached_emoji = Client.get().getEmoji(emoji_id);
if (!cached_emoji) return emoji_id;
return cached_emoji.html;
}

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 from './App.svelte';
import Instance from './instance.js';
const app = new App({
target: document.getElementById('app')

View file

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

View file

@ -1,5 +1,5 @@
<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';
export let post;
@ -10,7 +10,7 @@
<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.
<a href="/{post.user.mention}">{@html parseEmojis(post.user.name)}</a> boosted this post.
</span>
<span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time>

View file

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

View file

@ -1,5 +1,5 @@
<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';
export let post;
@ -13,7 +13,7 @@
</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>
<a href="/{post.user.mention}" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">

View file

@ -4,42 +4,42 @@
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 { parseOne as parseEmoji } from '../emoji.js';
import { play_sound } from '../sound.js';
export let post;
export let post_data;
let post_context = undefined;
let _post = post;
let post = post_data;
let is_boost = false;
if (_post.boost) {
if (post_data.boost) {
is_boost = true;
post_context = _post;
_post = _post.boost;
post_context = post_data;
post = post_data.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 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} />
<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 post.reactions as reaction}
<FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} 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="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" />
@ -63,17 +63,19 @@
background-color: var(--bg2);
}
.post-reactions {
margin-top: 8px;
:global(.post-reactions) {
margin-top: 16px;
display: flex;
flex-direction: row;
}
.post-actions {
:global(.post-actions) {
margin-top: 8px;
display: flex;
flex-direction: row;
}
.post-container :global(.emoji) {
position: relative;
top: 6px;
height: 26px;
height: 20px;
}
</style>

View file

@ -3,7 +3,7 @@
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 { parseText as parseEmojis, parseOne as parseEmoji } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
@ -24,7 +24,7 @@
<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>
<a href="/{post.user.mention}" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
@ -39,8 +39,8 @@
<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 post.reactions as reaction}
<FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" />
{/each}
</div>
<div class="post-actions">

View file

@ -1,9 +1,5 @@
import Instance from '../instance.js';
import User from '../user/user.js';
import { parse_one as parse_emoji } from '../emoji.js';
let post_cache = Object;
import { Client, server_types } from '../client/client.js';
import { parseOne as parseEmoji, EMOJI_REGEX } from '../emoji.js';
export default class Post {
id;
@ -21,70 +17,7 @@ export default class Post {
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() {
async rich_text() {
let text = this.text;
if (!text) return text;
@ -112,7 +45,8 @@ export default class Post {
}
// 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
let length = 1;
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);
// attempt to resolve mention to a user
let user = User.resolve_mention(mention);
let user = await Client.get().getUserByMention(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>";
'@' + user.username + '@' + user.host + "</a>";
if (current) current.text += out;
else response += out;
} else {
@ -136,9 +70,8 @@ export default class Post {
continue;
}
if (Instance.get_instance().type !== Instance.types.MASTODON) {
// 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
let length = text.substring(index).search(/\s|$/g);
let url = text.substring(index, index + length);
@ -148,21 +81,28 @@ export default class Post {
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 (allow_new && sample.match(/^:[\w\-.]{0,32}:/g)) {
// find the emoji name
let length = text.substring(index + 1).search(':');
if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = Client.get().getEmoji(emoji_name + '@' + this.user.host);
index += length + 2;
if (!emoji) {
let out = ':' + emoji_name + ':';
if (current) current.text += out;
else response += out;
index += emoji_code.length;
continue;
}
let out = emoji.html;
if (current) current.text += out;
else response += out;
continue;
}
// handle markdown

View file

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