migrate to unified "capabilities" API

This commit is contained in:
ari melody 2024-06-21 04:55:24 +01:00
parent 7779845056
commit 191212c940
Signed by: ari
GPG key ID: CF99829C92678188
6 changed files with 198 additions and 246 deletions

261
src/client/api.js Normal file
View file

@ -0,0 +1,261 @@
import { Client } from '../client/client.js';
import { capabilities } from '../client/instance.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 parsePost(post_data, 1);
if (!post) {
if (post === null || post === undefined) {
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.ok ? res.json() : false });
if (!data) return null;
const post = await parsePost(data, num_replies);
if (post === null || post === undefined) {
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 parseUser(data.account);
if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT))
post.text = data.text;
else
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.files = data.media_attachments;
post.url = data.url;
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.emojis = [];
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
if (client.instance.capabilities.includes(capabilities.REACTIONS)) {
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: 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(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 parseUser(data);
if (user === null || user === undefined) {
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;
}

View file

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