Merge 'capabilities API' (#1)

Reviewed-on: ari/spacesocial#1
This commit is contained in:
ari melody 2024-06-21 03:59:43 +00:00
commit c6f3cd0d96
7 changed files with 204 additions and 246 deletions

View file

@ -1,6 +1,6 @@
<script> <script>
import Feed from './Feed.svelte'; import Feed from './Feed.svelte';
import { Client, server_types } from './client/client.js'; import { Client } from './client/client.js';
let ready = Client.get().app && Client.get().app.token; let ready = Client.get().app && Client.get().app.token;
@ -25,7 +25,6 @@
}); });
} }
function log_out() { function log_out() {
Client.get().logout().then(() => { Client.get().logout().then(() => {
ready = false; ready = false;

View file

@ -1,98 +0,0 @@
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 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) {
return mastodonAPI.parseUser(data);
}
export function parseEmoji(data) {
return mastodonAPI.parseEmoji(data);
}
export async function getUser(user_id) {
return mastodonAPI.getUser(user_id);
}

View file

@ -1,4 +1,5 @@
import { Client } from '../client/client.js'; import { Client } from '../client/client.js';
import { capabilities } from '../client/instance.js';
import Post from '../post/post.js'; import Post from '../post/post.js';
import User from '../user/user.js'; import User from '../user/user.js';
import Emoji from '../emoji.js'; import Emoji from '../emoji.js';
@ -94,13 +95,15 @@ export async function getTimeline(last_post_id) {
let posts = []; let posts = [];
for (let i in data) { for (let i in data) {
const post_data = data[i]; const post_data = data[i];
const post = await client.api.parsePost(post_data, 1); const post = await parsePost(post_data, 1);
if (!post) { if (!post) {
if (post_data.id) { if (post === null || post === undefined) {
console.warn("Failed to parse post #" + post_data.id); if (post_data.id) {
} else { console.warn("Failed to parse post #" + post_data.id);
console.warn("Failed to parse post:"); } else {
console.warn(post_data); console.warn("Failed to parse post:");
console.warn(post_data);
}
} }
continue; continue;
} }
@ -115,10 +118,12 @@ export async function getPost(post_id, num_replies) {
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json()); }).then(res => { res.ok ? res.json() : false });
const post = await client.api.parsePost(data, num_replies); if (!data) return null;
if (!post) {
const post = await parsePost(data, num_replies);
if (post === null || post === undefined) {
if (data.id) { if (data.id) {
console.warn("Failed to parse post data #" + data.id); console.warn("Failed to parse post data #" + data.id);
} else { } else {
@ -133,56 +138,72 @@ export async function getPost(post_id, num_replies) {
export async function parsePost(data, num_replies) { export async function parsePost(data, num_replies) {
let client = Client.get(); let client = Client.get();
let post = new Post() let post = new Post()
post.id = data.id; post.id = data.id;
post.created_at = new Date(data.created_at); post.created_at = new Date(data.created_at);
post.user = await Client.get().api.parseUser(data.account); post.user = await parseUser(data.account);
post.text = data.content;
if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT))
post.text = data.text;
else
post.text = data.content;
post.warning = data.spoiler_text; post.warning = data.spoiler_text;
post.boost_count = data.reblogs_count; post.boost_count = data.reblogs_count;
post.reply_count = data.replies_count; post.reply_count = data.replies_count;
post.mentions = data.mentions; post.mentions = data.mentions;
post.reactions = data.reactions;
post.files = data.media_attachments; post.files = data.media_attachments;
post.url = data.url; 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.reply = null;
if (data.in_reply_to_id && num_replies > 0) {
post.reply = await getPost(data.in_reply_to_id, num_replies - 1);
// if the post returns null, we probably don't have permission to read it.
// we'll respect the thread's privacy, and leave it alone :)
if (post.reply === null) return false;
}
post.boost = data.reblog ? await parsePost(data.reblog, 1) : null; post.boost = data.reblog ? await parsePost(data.reblog, 1) : null;
post.emojis = []; post.emojis = [];
data.emojis.forEach(emoji_data => { data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0]; let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(Client.get().api.parseEmoji({ post.emojis.push(parseEmoji({
id: name + '@' + post.user.host, id: name + '@' + post.user.host,
name: name, name: name,
host: post.user.host, host: post.user.host,
url: emoji_data.url, url: emoji_data.url,
})); }));
}); });
post.reactions = [];
data.reactions.forEach(reaction_data => { if (client.instance.capabilities.includes(capabilities.REACTIONS)) {
if (/^[\w\-.@]+$/g.exec(reaction_data.name)) { post.reactions = [];
let name = reaction_data.name.split('@')[0]; data.reactions.forEach(reaction_data => {
let host = reaction_data.name.includes('@') ? reaction_data.name.split('@')[1] : client.instance.host; if (/^[\w\-.@]+$/g.exec(reaction_data.name)) {
post.reactions.push({ let name = reaction_data.name.split('@')[0];
count: reaction_data.count, let host = reaction_data.name.includes('@') ? reaction_data.name.split('@')[1] : client.instance.host;
emoji: Client.get().api.parseEmoji({ post.reactions.push({
id: name + '@' + host, count: reaction_data.count,
name: name, emoji: parseEmoji({
host: host, id: name + '@' + host,
url: reaction_data.url, name: name,
}), host: host,
me: reaction_data.me, url: reaction_data.url,
}); }),
} else { me: reaction_data.me,
if (reaction_data.name == '❤') reaction_data.name = '❤️'; // stupid heart unicode });
post.reactions.push({ } else {
count: reaction_data.count, if (reaction_data.name == '❤') reaction_data.name = '❤️'; // stupid heart unicode
emoji: { post.reactions.push({
html: reaction_data.name, count: reaction_data.count,
name: reaction_data.name, emoji: {
}, html: reaction_data.name,
me: reaction_data.me, name: reaction_data.name,
}); },
} me: reaction_data.me,
}); });
}
});
}
return post; return post;
} }
@ -201,7 +222,7 @@ export async function parseUser(data) {
emoji_data.id = emoji_data.shortcode + '@' + user.host; emoji_data.id = emoji_data.shortcode + '@' + user.host;
emoji_data.name = emoji_data.shortcode; emoji_data.name = emoji_data.shortcode;
emoji_data.host = user.host; emoji_data.host = user.host;
user.emojis.push(Client.get().api.parseEmoji(emoji_data)); user.emojis.push(parseEmoji(emoji_data));
}); });
Client.get().putCacheUser(user); Client.get().putCacheUser(user);
return user; return user;
@ -226,8 +247,8 @@ export async function getUser(user_id) {
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => res.json()); }).then(res => res.json());
const user = await Client.get().api.parseUser(data); const user = await parseUser(data);
if (!post) { if (user === null || user === undefined) {
if (data.id) { if (data.id) {
console.warn("Failed to parse user data #" + data.id); console.warn("Failed to parse user data #" + data.id);
} else { } else {

View file

@ -1,27 +1,11 @@
import * as mastodonAPI from '../api/mastodon.js'; import { Instance, server_types } from './instance.js';
import * as firefishAPI from '../api/firefish.js'; import * as api from './api.js';
let client = false; let client = false;
export const server_types = {
INCOMPATIBLE: "incompatible",
MASTODON: "mastodon",
FIREFISH: "firefish",
};
const save_name = "spacesocial"; 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: "firefish", type: server_types.FIREFISH },
{ version: "iceshrimp", type: server_types.FIREFISH },
{ version: "sharkey", type: server_types.FIREFISH },
];
export class Client { export class Client {
#api;
instance; instance;
app; app;
#cache; #cache;
@ -40,44 +24,29 @@ export class Client {
client = new Client(); client = new Client();
window.peekie = client; window.peekie = client;
client.load(); client.load();
if (client.instance && client.instance !== server_types.INCOMPATIBLE)
client.#configureAPI();
return client; return client;
} }
async init(host) { async init(host) {
if (host.startsWith("https://")) host = host.substring(8); if (host.startsWith("https://")) host = host.substring(8);
const url = `https://${host}/api/v1/instance`; const url = `https://${host}/api/v1/instance`;
const data = await fetch(url).then(res => res.json()).catch(error => { console.log(error) }); const data = await fetch(url).then(res => res.json()).catch(error => { console.error(error) });
if (!data) { if (!data) {
console.error(`Failed to connect to ${host}`); console.error(`Failed to connect to ${host}`);
alert(`Failed to connect to ${host}! Please try again later.`); alert(`Failed to connect to ${host}! Please try again later.`);
return false; return false;
} }
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.instance = new Instance(host, data.version);
if (this.instance.type == server_types.INCOMPATIBLE) { if (this.instance.type == server_types.INCOMPATIBLE) {
console.error(`Server ${host} is not supported - ${data.version}`); console.error(`Server ${host} is not supported - ${data.version}`);
alert(`Sorry, this app is not compatible with ${host}!`); alert(`Sorry, this app is not compatible with ${host}!`);
return false; return false;
} }
console.log(`Server is "${client.instance.type}" (or compatible).`); console.log(`Server is "${this.instance.type}" (or compatible) with capabilities: [${this.instance.capabilities}].`);
this.#configureAPI(); this.app = await api.createApp(host);
this.app = await this.api.createApp(host);
if (!this.app || !this.instance) { if (!this.app || !this.instance) {
console.error("Failed to create app. Check the network logs for details."); console.error("Failed to create app. Check the network logs for details.");
@ -89,29 +58,12 @@ export class Client {
return true; return true;
} }
#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() { getOAuthUrl() {
return this.api.getOAuthUrl(this.app.secret); return api.getOAuthUrl(this.app.secret);
} }
async getToken(code) { async getToken(code) {
const token = await this.api.getToken(code); const token = await api.getToken(code);
if (!token) { if (!token) {
console.error("Failed to obtain access token"); console.error("Failed to obtain access token");
return false; return false;
@ -120,15 +72,15 @@ export class Client {
} }
async revokeToken() { async revokeToken() {
return await this.api.revokeToken(); return await api.revokeToken();
} }
async getTimeline(last_post_id) { async getTimeline(last_post_id) {
return await this.api.getTimeline(last_post_id); return await api.getTimeline(last_post_id);
} }
async getPost(post_id, num_replies) { async getPost(post_id, num_replies) {
return await this.api.getPost(post_id, num_replies); return await api.getPost(post_id, num_replies);
} }
putCacheUser(user) { putCacheUser(user) {
@ -139,7 +91,7 @@ export class Client {
let user = this.cache.users[user_id]; let user = this.cache.users[user_id];
if (user) return user; if (user) return user;
user = await this.api.getUser(user_id); user = await api.getUser(user_id);
if (user) return user; if (user) return user;
return false; return false;
@ -166,7 +118,10 @@ export class Client {
save() { save() {
localStorage.setItem(save_name, JSON.stringify({ localStorage.setItem(save_name, JSON.stringify({
instance: this.instance, instance: {
host: this.instance.host,
version: this.instance.version,
},
app: this.app, app: this.app,
})); }));
} }
@ -175,13 +130,13 @@ export class Client {
let json = localStorage.getItem(save_name); let json = localStorage.getItem(save_name);
if (!json) return false; if (!json) return false;
let saved = JSON.parse(json); let saved = JSON.parse(json);
this.instance = saved.instance; this.instance = new Instance(saved.instance.host, saved.instance.version);
this.app = saved.app; this.app = saved.app;
return true; return true;
} }
async logout() { async logout() {
if (!this.instance || !this.app || !this.api) return; if (!this.instance || !this.app) return;
if (!await this.revokeToken()) { if (!await this.revokeToken()) {
console.warn("Failed to log out correctly; ditching the old tokens anyways."); console.warn("Failed to log out correctly; ditching the old tokens anyways.");
} }

68
src/client/instance.js Normal file
View file

@ -0,0 +1,68 @@
export const server_types = {
UNSUPPORTED: "unsupported",
MASTODON: "mastodon",
GLITCHSOC: "glitchsoc",
CHUCKYA: "chuckya",
FIREFISH: "firefish",
ICESHRIMP: "iceshrimp",
SHARKEY: "sharkey",
};
export const capabilities = {
MARKDOWN_CONTENT: "mdcontent",
REACTIONS: "reactions",
};
export class Instance {
host;
version;
capabilities;
type = server_types.UNSUPPORTED;
constructor(host, version) {
this.host = host;
this.version = version;
this.#setType(version);
this.capabilities = this.#getCapabilities(this.type);
}
#setType(version) {
if (version.constructor !== String) return;
let version_lower = version.toLowerCase();
for (let i = 1; i < Object.keys(server_types).length; i++) {
const check_type = Object.values(server_types)[i];
if (version_lower.includes(check_type)) {
this.type = check_type;
break;
}
}
if (this.type === server_types.UNSUPPORTED) return;
}
#getCapabilities(type) {
let c = [];
switch (type) {
case server_types.MASTODON:
break;
case server_types.GLITCHSOC:
c.push(capabilities.REACTIONS);
break;
case server_types.CHUCKYA:
c.push(capabilities.REACTIONS);
break;
case server_types.FIREFISH:
c.push(capabilities.REACTIONS);
break;
case server_types.ICESHRIMP:
c.push(capabilities.MARKDOWN_CONTENT);
c.push(capabilities.REACTIONS);
break;
case server_types.SHARKEY:
c.push(capabilities.REACTIONS);
break;
default:
break;
}
return c;
}
}

View file

@ -49,6 +49,12 @@
height: 24px!important; height: 24px!important;
} }
.post-text :global(blockquote) {
border-left: 4px solid #8888;
padding: .2em 2em;
margin: .8em 0;
}
.post-text :global(code) { .post-text :global(code) {
font-size: 1.2em; font-size: 1.2em;
} }

View file

@ -1,4 +1,5 @@
import { Client, server_types } from '../client/client.js'; import { Client } from '../client/client.js';
import { capabilities, server_types } from '../client/instance.js';
import { parseOne as parseEmoji, EMOJI_REGEX } from '../emoji.js'; import { parseOne as parseEmoji, EMOJI_REGEX } from '../emoji.js';
export default class Post { export default class Post {
@ -20,33 +21,36 @@ export default class Post {
async rich_text() { async rich_text() {
let text = this.text; let text = this.text;
if (!text) return text; if (!text) return text;
let client = Client.get();
const markdown_tokens = [ const markdown_tokens = [
{ tag: "pre", token: "```" }, { tag: "pre", token: "```" },
{ tag: "code", token: "`" }, { tag: "code", token: "`" },
{ tag: "strong", token: "**", regex: /\*{2}/g }, { tag: "strong", token: "**" },
{ tag: "strong", token: "__" }, { tag: "strong", token: "__" },
{ tag: "em", token: "*", regex: /\*/g }, { tag: "em", token: "*" },
{ tag: "em", token: "_" }, { tag: "em", token: "_" },
]; ];
let response = ""; let response = "";
let current; let md_layer;
let index = 0; let index = 0;
while (index < text.length) { while (index < text.length) {
let sample = text.substring(index); let sample = text.substring(index);
let allow_new = !current || !current.nostack; let md_nostack = !(md_layer && md_layer.nostack);
// handle newlines // handle newlines
if (allow_new && sample.startsWith('\n')) { if (md_nostack && sample.startsWith('\n')) {
response += "<br>"; response += "<br>";
index++; index++;
continue; continue;
} }
// handle mentions // handle mentions
// TODO: setup a better system for handling different server capabilities if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT)
if (Client.get().instance.type !== server_types.MASTODON && allow_new && sample.match(/^@[\w\-.]+@[\w\-.]+/g)) { && md_nostack
&& 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++;
@ -56,12 +60,12 @@ 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 = await Client.get().getUserByMention(mention); let user = await client.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.username + '@' + user.host + "</a>"; '@' + user.username + '@' + user.host + "</a>";
if (current) current.text += out; if (md_layer) md_layer.text += out;
else response += out; else response += out;
} else { } else {
response += mention; response += mention;
@ -71,72 +75,75 @@ export default class Post {
} }
// handle links // handle links
if (Client.get().instance.type !== server_types.MASTODON && allow_new && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)) { if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT)
&& md_nostack
&& 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);
let out = `<a href="${url}">${url}</a>`; let out = `<a href="${url}">${url}</a>`;
if (current) current.text += out; if (md_layer) md_layer.text += out;
else response += out; else response += out;
index += length; index += length;
continue; continue;
} }
// handle emojis // handle emojis
if (allow_new && sample.match(/^:[\w\-.]{0,32}:/g)) { if (md_nostack && sample.match(/^:[\w\-.]{0,32}:/g)) {
// find the emoji name // find the emoji name
let length = text.substring(index + 1).search(':'); let length = text.substring(index + 1).search(':');
if (length <= 0) return text; if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1); let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = Client.get().getEmoji(emoji_name + '@' + this.user.host); let emoji = client.getEmoji(emoji_name + '@' + this.user.host);
index += length + 2; index += length + 2;
if (!emoji) { if (!emoji) {
let out = ':' + emoji_name + ':'; let out = ':' + emoji_name + ':';
if (current) current.text += out; if (md_layer) md_layer.text += out;
else response += out; else response += out;
continue; continue;
} }
let out = emoji.html; let out = emoji.html;
if (current) current.text += out; if (md_layer) md_layer.text += out;
else response += out; else response += out;
continue; continue;
} }
// handle markdown // handle markdown
// TODO: handle misskey-flavoured markdown // TODO: handle misskey-flavoured markdown(?)
if (current) { if (md_layer) {
// try to pop stack // try to pop layer
if (sample.startsWith(current.token)) { if (sample.startsWith(md_layer.token)) {
index += current.token.length; index += md_layer.token.length;
let out = `<${current.tag}>${current.text}</${current.tag}>`; let out = `<${md_layer.tag}>${md_layer.text}</${md_layer.tag}>`;
if (current.token === '```') if (md_layer.token === '```')
out = `<code><pre>${current.text}</pre></code>`; out = `<code><pre>${md_layer.text}</pre></code>`;
if (current.parent) current.parent.text += out; if (md_layer.parent) md_layer.parent.text += out;
else response += out; else response += out;
current = current.parent; md_layer = md_layer.parent;
} else { } else {
current.text += sample[0]; md_layer.text += sample[0];
index++; index++;
} }
} else if (allow_new) { } else if (md_nostack) {
// can we add to stack? // should we add a layer?
let pushed = false; let pushed = false;
for (let i = 0; i < markdown_tokens.length; i++) { for (let i = 0; i < markdown_tokens.length; i++) {
let item = markdown_tokens[i]; let item = markdown_tokens[i];
if (sample.startsWith(item.token)) { if (sample.startsWith(item.token)) {
let new_current = { let new_md_layer = {
token: item.token, token: item.token,
tag: item.tag, tag: item.tag,
text: "", text: "",
parent: current, parent: md_layer,
}; };
if (item.token === '```' || item.token === '`') new_current.nostack = true; if (item.token === '```' || item.token === '`') new_md_layer.nostack = true;
current = new_current; md_layer = new_md_layer;
pushed = true; pushed = true;
index += current.token.length; index += md_layer.token.length;
break; break;
} }
} }
@ -148,11 +155,11 @@ export default class Post {
} }
// destroy the remaining stack // destroy the remaining stack
while (current) { while (md_layer) {
let out = current.token + current.text; let out = md_layer.token + md_layer.text;
if (current.parent) current.parent.text += out; if (md_layer.parent) md_layer.parent.text += out;
else response += out; else response += out;
current = current.parent; md_layer = md_layer.parent;
} }
return response; return response;