diff --git a/CHANGELOGS.md b/CHANGELOGS.md index d4daa7b..4c81497 100644 --- a/CHANGELOGS.md +++ b/CHANGELOGS.md @@ -1,8 +1,3 @@ -# Campfire v0.4.0 -- Infinite scrolling notifications -- Fixed custom emotes in the sidebar profile display -- Huge refactor, along with some improved documentation - # Campfire v0.3.0 - Added notifications view - Many more background tweaks, fixes, and optimisations diff --git a/src/lib/account.js b/src/lib/account.js deleted file mode 100644 index 7bfd696..0000000 --- a/src/lib/account.js +++ /dev/null @@ -1,52 +0,0 @@ -import { server } from '$lib/client/server.js'; -import { parseEmoji, renderEmoji } from '$lib/emoji.js'; -import { get, writable } from 'svelte/store'; - -const cache = writable({}); - -/** - * Parses an account using API data, and returns a writable store object. - * @param {Object} data - * @param {number} ancestor_count - */ -export function parseAccount(data) { - if (!data) { - console.error("Attempted to parse account data but no data was provided"); - return null; - } - let account = get(cache)[data.id]; - if (account) return account; - // cache miss! - - account = {}; - account.id = data.id; - account.nickname = data.display_name.trim(); - account.username = data.username; - account.name = account.nickname || account.username; - account.avatar_url = data.avatar; - account.url = data.url; - - if (data.acct.includes('@')) - account.host = data.acct.split('@')[1]; - else - account.host = get(server).host; - - account.mention = "@" + account.username; - if (account.host != get(server).host) - account.mention += "@" + account.host; - - account.emojis = {}; - data.emojis.forEach(emoji => { - account.emojis[emoji.shortcode] = parseEmoji(emoji.shortcode, emoji.url); - }); - - account.rich_name = account.nickname ? renderEmoji(account.nickname, account.emojis) : account.username; - - cache.update(cache => { - cache[account.id] = account; - return cache; - }); - - return account; -} - diff --git a/src/lib/api.js b/src/lib/api.js deleted file mode 100644 index 8006213..0000000 --- a/src/lib/api.js +++ /dev/null @@ -1,339 +0,0 @@ -/** - * GET /api/v1/instance - * @param {string} host - The domain of the target server. - */ -export async function getInstance(host) { - const data = await fetch(`https://${host}/api/v1/instance`) - .then(res => res.json()) - .catch(error => console.error(error)); - return data ? data : false; -} - -/** - * POST /api/v1/apps - * Attempts to create an application for a given server host. - * @param {string} host - The domain of the target server. - */ -export async function createApp(host) { - let form = new FormData(); - form.append("client_name", "Campfire"); - form.append("redirect_uris", `${location.origin}/callback`); - form.append("scopes", "read write push"); - form.append("website", "https://campfire.bliss.town"); - - 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, - }; -} - -/** - * Returns the OAuth authorization url for the target server. - * @param {string} host - The domain of the target server. - * @param {string} app_id - The application id for the target server. - */ -export function getOAuthUrl(host, app_id) { - return `https://${host}/oauth/authorize` + - `?client_id=${app_id}` + - "&scope=read+write+push" + - `&redirect_uri=${location.origin}/callback` + - "&response_type=code"; -} - -/** - * POST /oauth/token - * Attempts to generate an OAuth token. - * Returns false on failure. - * @param {string} host - The domain of the target server. - * @param {string} client_id - The application id. - * @param {string} secret - The application secret. - * @param {string} code - The authorization code provided by OAuth. - */ -export async function getToken(host, client_id, secret, code) { - let form = new FormData(); - form.append("client_id", client_id); - form.append("client_secret", 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://${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; -} - -/** - * POST /oauth/revoke - * Attempts to revoke an OAuth token. - * Returns false on failure. - * @param {string} host - The domain of the target server. - * @param {string} client_id - The application id. - * @param {string} secret - The application secret. - * @param {string} token - The application token. - */ -export async function revokeToken(host, client_id, secret, token) { - let form = new FormData(); - form.append("client_id", client_id); - form.append("client_secret", secret); - form.append("token", token); - - const res = await fetch(`https://${host}/oauth/revoke`, { - method: "POST", - body: form, - }) - .catch(error => { - console.error(error); - return false; - }); - - if (!res.ok) return false; - return true; -} - -/** - * GET /api/v1/accounts/verify_credentials - * This endpoint returns information about the client account, - * and other useful data. - * Returns false on failure. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - */ -export async function verifyCredentials(host, token) { - let url = `https://${host}/api/v1/accounts/verify_credentials`; - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": "Bearer " + token } - }).then(res => res.json()); - - return data; -} - -/** - * GET /api/v1/notifications - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} max_id - If provided, only shows notifications after this ID. - * @param {string} limit - The maximum number of notifications to retrieve (default 40). - * @param {string} types - A list of notification types to filter to. - */ -export async function getNotifications(host, token, max_id, limit, types) { - let url = `https://${host}/api/v1/notifications`; - - let params = new URLSearchParams(); - if (max_id) params.append("max_id", max_id); - if (limit) params.append("limit", limit); - if (types) params.append("types", types.join(',')); - const params_string = params.toString(); - if (params_string) url += '?' + params_string; - - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": "Bearer " + token } - }).then(res => res.json()); - - return data; -} - -/** - * GET /api/v1/timelines/{timeline} - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} timeline - The name of the timeline to pull (default "home"). - * @param {string} max_id - If provided, only shows posts after this ID. - */ -export async function getTimeline(host, token, timeline, max_id) { - let url = `https://${host}/api/v1/timelines/${timeline || "home"}`; - - let params = new URLSearchParams(); - if (max_id) params.append("max_id", max_id); - const params_string = params.toString(); - if (params_string) url += '?' + params_string; - - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": token ? `Bearer ${token}` : null } - }).then(res => res.json()); - - return data; -} - -/** - * GET /api/v1/statuses/{post_id}. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to fetch. - */ -export async function getPost(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}`; - - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": token ? `Bearer ${token}` : null } - }).then(res => res.json()); - - return data; -} - -/** - * GET /api/v1/statuses/{post_id}/context. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to fetch. - */ -export async function getPostContext(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}/context`; - - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": token ? `Bearer ${token}` : null } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/reblog. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to boost. - */ -export async function boostPost(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}/reblog`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/unreblog. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to unboost. - */ -export async function unboostPost(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}/unreblog`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/favourite. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to favourite. - */ -export async function favouritePost(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}/favourite`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/unfavourite. - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to unfavourite. - */ -export async function unfavouritePost(host, token, post_id) { - let url = `https://${host}/api/v1/statuses/${post_id}/unfavourite`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/react/{shortcode} - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to favourite. - * @param {string} shortcode - The shortcode of the emote to react with. - */ -export async function reactPost(host, token, post_id, shortcode) { - // note: reacting with foreign emotes is unsupported on most servers - // chuckya appears to allow this, but other servers tested have - // not demonstrated this. - let url = `https://${host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * POST /api/v1/statuses/{post_id}/unreact/{shortcode} - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} post_id - The ID of the post to favourite. - * @param {string} shortcode - The shortcode of the reaction emote to remove. - */ -export async function unreactPost(host, token, post_id, shortcode) { - let url = `https://${host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`; - - const data = await fetch(url, { - method: 'POST', - headers: { "Authorization": `Bearer ${token}` } - }).then(res => res.json()); - - return data; -} - -/** - * GET /api/v1/accounts/{user_id} - * @param {string} host - The domain of the target server. - * @param {string} token - The application token. - * @param {string} user_id - The ID of the user to fetch. - */ -export async function getUser(host, token, user_id) { - let url = `https://${host}/api/v1/accounts/${user_id}`; - - const data = await fetch(url, { - method: 'GET', - headers: { "Authorization": token ? `Bearer ${token}` : null } - }).then(res => res.json()); - - return data; -} diff --git a/src/lib/client/api.js b/src/lib/client/api.js new file mode 100644 index 0000000..09e6514 --- /dev/null +++ b/src/lib/client/api.js @@ -0,0 +1,339 @@ +import { client } from '$lib/client/client.js'; +import { user } from '$lib/stores/user.js'; +import { capabilities } from '../client/instance.js'; +import Post from '$lib/post.js'; +import User from '$lib/user/user.js'; +import Emoji from '$lib/emoji.js'; +import { get } from 'svelte/store'; + +export async function createApp(host) { + let form = new FormData(); + form.append("client_name", "Campfire"); + form.append("redirect_uris", `${location.origin}/callback`); + form.append("scopes", "read write push"); + form.append("website", "https://campfire.bliss.town"); + + 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() { + return `https://${get(client).instance.host}/oauth/authorize` + + `?client_id=${get(client).app.id}` + + "&scope=read+write+push" + + `&redirect_uri=${location.origin}/callback` + + "&response_type=code"; +} + +export async function getToken(code) { + let form = new FormData(); + form.append("client_id", get(client).app.id); + form.append("client_secret", get(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://${get(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 form = new FormData(); + form.append("client_id", get(client).app.id); + form.append("client_secret", get(client).app.secret); + form.append("token", get(client).app.token); + + const res = await fetch(`https://${get(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 verifyCredentials() { + let url = `https://${get(client).instance.host}/api/v1/accounts/verify_credentials`; + const data = await fetch(url, { + method: 'GET', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => res.json()); + + return data; +} + +export async function getNotifications(since_id, limit, types) { + if (!get(user)) return false; + + let url = `https://${get(client).instance.host}/api/v1/notifications`; + + let params = new URLSearchParams(); + if (since_id) params.append("since_id", since_id); + if (limit) params.append("limit", limit); + if (types) params.append("types", types.join(',')); + const params_string = params.toString(); + if (params_string) url += '?' + params_string; + + const data = await fetch(url, { + method: 'GET', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => res.json()); + + return data; +} + +export async function getTimeline(last_post_id) { + if (!get(user)) return false; + let url = `https://${get(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 " + get(client).app.token } + }).then(res => res.json()); + + return data; +} + +export async function getPost(post_id, ancestor_count) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}`; + const data = await fetch(url, { + method: 'GET', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function getPostContext(post_id) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/context`; + const data = await fetch(url, { + method: 'GET', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function boostPost(post_id) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/reblog`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function unboostPost(post_id) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreblog`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function favouritePost(post_id) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/favourite`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function unfavouritePost(post_id) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unfavourite`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function reactPost(post_id, shortcode) { + // for whatever reason (at least in my testing on iceshrimp) + // using shortcodes for external emoji results in a fallback + // to the default like emote. + // identical api calls on chuckya instances do not display + // this behaviour. + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function unreactPost(post_id, shortcode) { + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`; + const data = await fetch(url, { + method: 'POST', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => { return res.ok ? res.json() : false }); + + if (data === false) return false; + return data; +} + +export async function parsePost(data, ancestor_count) { + let post = new Post(); + + post.text = data.content; + post.html = data.content; + + post.reply = null; + if ((data.in_reply_to_id || data.reply) && + ancestor_count !== 0 + ) { + const reply_data = data.reply || await getPost(data.in_reply_to_id, ancestor_count - 1); + // if the post returns false, we probably don't have permission to read it. + // we'll respect the thread's privacy, and leave it alone :) + if (!reply_data) return false; + post.reply = await parsePost(reply_data, ancestor_count - 1, false); + } + + post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null; + + post.id = data.id; + post.created_at = new Date(data.created_at); + post.user = await parseUser(data.account); + post.warning = data.spoiler_text; + post.boost_count = data.reblogs_count; + post.reply_count = data.replies_count; + post.favourite_count = data.favourites_count; + post.favourited = data.favourited; + post.boosted = data.reblogged; + post.mentions = data.mentions; + post.files = data.media_attachments; + post.url = data.url; + post.visibility = data.visibility; + + post.emojis = []; + if (data.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 (data.reactions && get(client).instance.capabilities.includes(capabilities.REACTIONS)) { + post.reactions = parseReactions(data.reactions); + } + return post; +} + +export async function parseUser(data) { + if (!data) { + console.error("Attempted to parse user data but no data was provided"); + return null; + } + let user = await get(client).getCacheUser(data.id); + + if (user) return user; + // cache miss! + + user = new User(); + user.id = data.id; + user.nickname = data.display_name.trim(); + user.username = data.username; + user.avatar_url = data.avatar; + user.url = data.url; + + if (data.acct.includes('@')) + user.host = data.acct.split('@')[1]; + else + user.host = get(client).instance.host; + + 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)); + }); + + get(client).putCacheUser(user); + return user; +} + +export function parseReactions(data) { + let reactions = []; + data.forEach(reaction_data => { + let reaction = { + count: reaction_data.count, + name: reaction_data.name, + me: reaction_data.me, + }; + if (reaction_data.url) reaction.url = reaction_data.url; + reactions.push(reaction); + }); + return reactions; +} + +export function parseEmoji(data) { + let emoji = new Emoji( + data.id, + data.name, + data.host, + data.url, + ); + get(client).putCacheEmoji(emoji); + return emoji; +} + +export async function getUser(user_id) { + let url = `https://${get(client).instance.host}/api/v1/accounts/${user_id}`; + const data = await fetch(url, { + method: 'GET', + headers: { "Authorization": "Bearer " + get(client).app.token } + }).then(res => res.json()); + + return data; +} diff --git a/src/lib/client/app.js b/src/lib/client/app.js deleted file mode 100644 index dc549a7..0000000 --- a/src/lib/client/app.js +++ /dev/null @@ -1,34 +0,0 @@ -import { writable } from 'svelte/store'; -import { app_name } from '$lib/config.js'; -import { browser } from "$app/environment"; - -// if app is falsy, assume user has not begun the login process. -// if app.token is falsy, assume user has not logged in. -export const app = writable(loadApp()); - -// write to localStorage on each update -app.subscribe(app => { - saveApp(app); -}); - -/** - * Saves the provided app to localStorage. - * If `app` is falsy, data is removed from localStorage. - * @param {Object} app - */ -function saveApp(app) { - if (!browser) return; - if (!app) localStorage.removeItem(app_name + "_app"); - localStorage.setItem(app_name + "_app", JSON.stringify(app)); -} - -/** - * Returns application data loaded from localStorage, if it exists. - * Otherwise, returns false. - */ -function loadApp() { - if (!browser) return; - let data = localStorage.getItem(app_name + "_app"); - if (!data) return false; - return JSON.parse(data); -} diff --git a/src/lib/client/client.js b/src/lib/client/client.js new file mode 100644 index 0000000..b1beb6e --- /dev/null +++ b/src/lib/client/client.js @@ -0,0 +1,192 @@ +import { Instance, server_types } from './instance.js'; +import * as api from './api.js'; +import { get, writable } from 'svelte/store'; +import { last_read_notif_id } from '$lib/notifications.js'; +import { user, logged_in } from '$lib/stores/user.js'; + +export const client = writable(false); + +const save_name = "campfire"; + +export class Client { + instance; + app; + #cache; + + constructor() { + this.instance = null; + this.app = null; + this.cache = { + users: {}, + emojis: {}, + }; + } + + 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.error(error) }); + if (!data) { + console.error(`Failed to connect to ${host}`); + return `Failed to connect to ${host}!`; + } + + this.instance = new Instance(host, data.version); + if (this.instance.type == server_types.UNSUPPORTED) { + console.warn(`Server ${host} is unsupported - ${data.version}`); + if (!confirm( + `This app does not officially support ${host}. ` + + `Things may break, or otherwise not work as epxected! ` + + `Are you sure you wish to continue?` + )) return false; + } else { + console.log(`Server is "${this.instance.type}" (or compatible) with capabilities: [${this.instance.capabilities}].`); + } + + this.app = await api.createApp(host); + + if (!this.app || !this.instance) { + console.error("Failed to create app. Check the network logs for details."); + return false; + } + + this.save(); + + client.set(this); + + return true; + } + + getOAuthUrl() { + return api.getOAuthUrl(this.app.secret); + } + + async getToken(code) { + const token = await api.getToken(code); + if (!token) { + console.error("Failed to obtain access token"); + return false; + } + return token; + } + + async revokeToken() { + return await api.revokeToken(); + } + + async getNotifications(since_id, limit, types) { + return await api.getNotifications(since_id, limit, types); + } + + async getTimeline(last_post_id) { + return await api.getTimeline(last_post_id); + } + + async getPost(post_id, parent_replies, child_replies) { + return await api.getPost(post_id, parent_replies, child_replies); + } + + async getPostContext(post_id) { + return await api.getPostContext(post_id); + } + + async boostPost(post_id) { + return await api.boostPost(post_id); + } + + async unboostPost(post_id) { + return await api.unboostPost(post_id); + } + + async favouritePost(post_id) { + return await api.favouritePost(post_id); + } + + async unfavouritePost(post_id) { + return await api.unfavouritePost(post_id); + } + + async reactPost(post_id, shortcode) { + return await api.reactPost(post_id, shortcode); + } + + async unreactPost(post_id, shortcode) { + return await api.unreactPost(post_id, shortcode); + } + + putCacheUser(user) { + this.cache.users[user.id] = user; + client.set(this); + } + + async getCacheUser(user_id) { + let user = this.cache.users[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; + client.set(this); + } + + getEmoji(emoji_id) { + let emoji = this.cache.emojis[emoji_id]; + if (!emoji) return false; + return emoji; + } + + async getUser(user_id) { + return await api.getUser(user_id); + } + + save() { + if (typeof localStorage === typeof undefined) return; + localStorage.setItem(save_name, JSON.stringify({ + version: APP_VERSION, + instance: { + host: this.instance.host, + version: this.instance.version, + }, + last_read_notif_id: get(last_read_notif_id), + app: this.app, + })); + } + + load() { + if (typeof localStorage === typeof undefined) return; + let json = localStorage.getItem(save_name); + if (!json) return false; + let saved = JSON.parse(json); + if (!saved.version || saved.version !== APP_VERSION) { + localStorage.removeItem(save_name); + return false; + } + this.instance = new Instance(saved.instance.host, saved.instance.version); + last_read_notif_id.set(saved.last_read_notif_id || 0); + this.app = saved.app; + client.set(this); + return true; + } + + async logout() { + if (!this.instance || !this.app) return; + if (!await this.revokeToken()) { + console.warn("Failed to log out correctly; ditching the old tokens anyways."); + } + localStorage.removeItem(save_name); + logged_in.set(false); + client.set(new Client()); + console.log("Logged out successfully."); + } +} diff --git a/src/lib/client/instance.js b/src/lib/client/instance.js new file mode 100644 index 0000000..92003e8 --- /dev/null +++ b/src/lib/client/instance.js @@ -0,0 +1,70 @@ +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) { + this.type = server_types.UNSUPPORTED; + 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; + 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: + // more trouble than it's worth atm + // the server already hands this to us ;p + //c.push(capabilities.MARKDOWN_CONTENT); + c.push(capabilities.REACTIONS); + break; + case server_types.SHARKEY: + c.push(capabilities.REACTIONS); + break; + default: + break; + } + return c; + } +} diff --git a/src/lib/client/server.js b/src/lib/client/server.js deleted file mode 100644 index 292ed5d..0000000 --- a/src/lib/client/server.js +++ /dev/null @@ -1,138 +0,0 @@ -import * as api from '$lib/api.js'; -import { writable } from 'svelte/store'; -import { app_name } from '$lib/config.js'; -import { browser } from "$app/environment"; - -const server_types = { - UNSUPPORTED: "unsupported", - MASTODON: "mastodon", - GLITCHSOC: "glitchsoc", - CHUCKYA: "chuckya", - FIREFISH: "firefish", - ICESHRIMP: "iceshrimp", - SHARKEY: "sharkey", - AKKOMA: "akkoma", // TODO: verify - PLEROMA: "pleroma", // TODO: verify -}; - -export const capabilities = { - MARKDOWN_CONTENT: "mdcontent", - REACTIONS: "reactions", -}; - -// if server is falsy, assume user has not begun the login process. -export let server = writable(loadServer()); - -// write to localStorage on each update -server.subscribe(server => { - saveServer(server); -}); - -/** - * Attempts to create an server object using a given hostname. - * @param {string} host - The domain of the target server. - */ -export async function createServer(host) { - if (!host) { - console.error("Attempted to create server without providing a hostname"); - return false; - } - if (host.startsWith("http://")) { - console.error("Cowardly refusing to connect to an insecure server"); - return false; - } - - let server = {}; - server.host = host; - - if (host.startsWith("https://")) host = host.substring(8); - const data = await api.getInstance(host); - if (!data) { - console.error(`Failed to connect to ${host}`); - return false; - } - - server.version = data.version; - server.type = getType(server.version); - server.capabilities = getCapabilities(server.type); - - if (server.type === server_types.UNSUPPORTED) { - console.warn(`Server ${host} is unsupported (${server.version}). Things may break, or not work as expected`); - } else { - console.log(`Server detected as "${server.type}" (${server.version}) with capabilities: {${server.capabilities.join(', ')}}`); - } - - return server; -} - -/** - * Saves the provided server to localStorage. - * If `server` is falsy, data is removed from localStorage. - * @param {Object} server - */ -function saveServer(server) { - if (!browser) return; - if (!server) localStorage.removeItem(app_name + "_server"); - localStorage.setItem(app_name + "_server", JSON.stringify(server)); -} - -/** - * Returns server data loaded from localStorage, if it exists. - * Otherwise, returns false. - */ -function loadServer() { - if (!browser) return; - let data = localStorage.getItem(app_name + "_server"); - if (!data) return false; - return JSON.parse(data); -} - -/** - * Returns the type of an server, inferred from its version string. - * @param {string} version - * @returns the inferred server_type - */ -function getType(version) { - if (version.constructor !== String) return; - let version_lower = version.toLowerCase(); - for (let i = 1; i < Object.keys(server_types).length; i++) { - const type = Object.values(server_types)[i]; - if (version_lower.includes(type)) { - return type; - } - } - return server_types.UNSUPPORTED; -} - -/** - * Returns a list of capabilities for a given server_type. - * @param {string} type - */ -function 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: - // more trouble than it's worth atm - // mastodon API already hands html to us - //c.push(capabilities.MARKDOWN_CONTENT); - c.push(capabilities.REACTIONS); - break; - case server_types.SHARKEY: - c.push(capabilities.REACTIONS); - break; - default: - break; - } - return c; -} diff --git a/src/lib/config.js b/src/lib/config.js deleted file mode 100644 index ccb3cdc..0000000 --- a/src/lib/config.js +++ /dev/null @@ -1 +0,0 @@ -export const app_name = "campfire"; diff --git a/src/lib/emoji.js b/src/lib/emoji.js index 29385c3..89df2d1 100644 --- a/src/lib/emoji.js +++ b/src/lib/emoji.js @@ -1,27 +1,52 @@ +import { client } from './client/client.js'; import { get } from 'svelte/store'; -export const EMOJI_REGEX = /:[\w\-.]{0,32}:/g; -export function parseEmoji(shortcode, url) { - let emoji = { shortcode, url }; - if (emoji.shortcode == '❤') emoji.shortcode = '❤️'; // stupid heart unicode - emoji.html = `${emoji.shortcode}`; - return emoji; +export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g; +export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g; + +export default class Emoji { + name; + url; + + constructor(id, name, host, url) { + this.id = id; + this.name = name; + this.host = host; + this.url = url; + } + + get html() { + if (this.url) + return `${this.name}`; + else + return `${this.name}`; + } } -export function renderEmoji(text, emoji_list) { +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; - // find the closing comma + // 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 = get(client).getEmoji(emoji_name + '@' + host); - // see if emoji is valid - let shortcode = text.substring(index + 1, index + length + 1); - let emoji = emoji_list[shortcode]; - let replace = emoji ? emoji.html : shortcode; - - return text.substring(0, index) + replace + renderEmoji(text.substring(index + length + 2), emoji_list); + if (emoji) { + return text.substring(0, index) + emoji.html + + parseText(text.substring(index + length + 2), host); + } + return text.substring(0, index + length + 1) + + parseText(text.substring(index + length + 1), host); +} + +export function parseOne(emoji_id) { + if (emoji_id == '❤') return '❤️'; // stupid heart unicode + if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id; + let cached_emoji = get(client).getEmoji(emoji_id); + if (!cached_emoji) return emoji_id; + return cached_emoji.html; } diff --git a/src/lib/notifications.js b/src/lib/notifications.js index cb7945f..bbdc69f 100644 --- a/src/lib/notifications.js +++ b/src/lib/notifications.js @@ -1,88 +1,40 @@ -import * as api from '$lib/api.js'; -import { server } from '$lib/client/server.js'; -import { app } from '$lib/client/app.js'; -import { app_name } from '$lib/config.js'; +import { client } from '$lib/client/client.js'; +import * as api from '$lib/client/api.js'; import { get, writable } from 'svelte/store'; -import { browser } from '$app/environment'; -import { parsePost } from '$lib/post.js'; -import { parseAccount } from '$lib/account.js'; -const prefix = app_name + '_notif_'; - -export const notifications = writable([]); -export const unread_notif_count = writable(load("unread_count")); -export const last_read_notif_id = writable(load("last_read")); - -unread_notif_count.subscribe(count => save("unread_count", count)); -last_read_notif_id.subscribe(id => save("last_read", id)); - -/** - * Saves the provided data to localStorage. - * If `data` is falsy, the record is removed from localStorage. - * @param {Object} name - * @param {any} data - */ -function save(name, data) { - if (!browser) return; - if (data) { - localStorage.setItem(prefix + name, data); - } else { - localStorage.removeItem(prefix + name); - } -} - -/** - * Returns named data loaded from localStorage, if it exists. - * Otherwise, returns false. - */ -function load(name) { - if (!browser) return; - let data = localStorage.getItem(prefix + name); - return data ? data : false; -} +export let notifications = writable([]); +export let unread_notif_count = writable(0); +export let last_read_notif_id = writable(0); let loading; -export async function getNotifications(clean) { +export async function getNotifications() { if (loading) return; // no spamming!! loading = true; - let last_id = false; - if (!clean && get(notifications).length > 0) - last_id = get(notifications)[get(notifications).length - 1].id; - - const notif_data = await api.getNotifications( - get(server).host, - get(app).token, - last_id - ); - - if (!notif_data) { - console.error(`Failed to retrieve notifications.`); - loading = false; - return; - } - - if (clean) notifications.set([]); - - for (let i in notif_data) { - let notif = notif_data[i]; - notif.accounts = [ await parseAccount(notif.account) ]; - if (get(notifications).length > 0) { - let prev = get(notifications)[get(notifications).length - 1]; - if (notif.type === prev.type) { - if (prev.status && notif.status && prev.status.id === notif.status.id) { - notifications.update(notifications => { - notifications[notifications.length - 1].accounts.push(notif.accounts[0]); - return notifications; - }); - continue; + api.getNotifications().then(async data => { + if (!data || data.length <= 0) return; + notifications.set([]); + for (let i in data) { + let notif = data[i]; + notif.accounts = [ await api.parseUser(notif.account) ]; + if (get(notifications).length > 0) { + let prev = get(notifications)[get(notifications).length - 1]; + if (notif.type === prev.type) { + if (prev.status && notif.status && prev.status.id === notif.status.id) { + notifications.update(notifications => { + notifications[notifications.length - 1].accounts.push(notif.accounts[0]); + return notifications; + }); + continue; + } } } + notif.status = notif.status ? await api.parsePost(notif.status, 0, false) : null; + notifications.update(notifications => [...notifications, notif]); } - notif.status = notif.status ? await parsePost(notif.status, 0, false) : null; - notifications.update(notifications => [...notifications, notif]); - } - if (!last_id) last_read_notif_id.set(notif_data[0].id); - if (!last_id) unread_notif_count.set(0); - loading = false; + last_read_notif_id.set(data[0].id); + unread_notif_count.set(0); + get(client).save(); + loading = false; + }); } diff --git a/src/lib/post.js b/src/lib/post.js index e8700f5..9b6d10f 100644 --- a/src/lib/post.js +++ b/src/lib/post.js @@ -1,82 +1,177 @@ -import * as api from '$lib/api.js'; -import { server } from '$lib/client/server.js'; -import { app } from '$lib/client/app.js'; -import { parseAccount } from '$lib/account.js'; -import { parseEmoji, renderEmoji } from '$lib/emoji.js'; -import { get, writable } from 'svelte/store'; +import { parseText as parseEmoji } from './emoji.js'; -const cache = writable({}); +export default class Post { + id; + created_at; + user; + text; + warning; + boost_count; + reply_count; + favourite_count; + favourited; + boosted; + mentions; + reactions; + emojis; + files; + url; + reply; + reply_id; + replies; + boost; + visibility; -/** - * Parses a post using API data, and returns a writable store object. - * @param {Object} data - * @param {number} ancestor_count - */ -export async function parsePost(data, ancestor_count) { - let post = {}; - if (!ancestor_count) ancestor_count = 0; - - post.html = data.content; - - post.reply = null; - if ((data.in_reply_to_id || data.reply) && ancestor_count !== 0) { - const reply_data = data.reply || await api.getPost(get(server).host, get(app).token, data.in_reply_to_id); - // if the post returns false, we probably don't have permission to read it. - // we'll respect the thread's privacy, and leave it alone :) - if (!reply_data) return false; - post.reply = await parsePost(reply_data, ancestor_count - 1, false); + async rich_text() { + return parseEmoji(this.text, this.user.host); } - post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null; + /* + async rich_text() { + let text = this.text; + if (!text) return text; + let client = Client.get(); - post.id = data.id; - post.created_at = new Date(data.created_at); - post.account = await parseAccount(data.account); - post.warning = data.spoiler_text; - post.reply_count = data.replies_count; - post.boost_count = data.reblogs_count; - post.boosted = data.reblogged; - post.favourite_count = data.favourites_count; - post.favourited = data.favourited; - post.mentions = data.mentions; - post.media = data.media_attachments; - post.url = data.url; - post.visibility = data.visibility; + const markdown_tokens = [ + { tag: "pre", token: "```" }, + { tag: "code", token: "`" }, + { tag: "strong", token: "**" }, + { tag: "strong", token: "__" }, + { tag: "em", token: "*" }, + { tag: "em", token: "_" }, + ]; - post.emojis = []; - data.emojis.forEach(emoji => { - post.emojis[emoji.shortcode] = parseEmoji(emoji.shortcode, emoji.url); - }); + let response = ""; + let md_layer; + let index = 0; + while (index < text.length) { + let sample = text.substring(index); + let md_nostack = !(md_layer && md_layer.nostack); - if (data.reactions) post.reactions = parseReactions(data.reactions); + // handle newlines + if (md_nostack && sample.startsWith('\n')) { + response += "
"; + index++; + continue; + } - post.rich_text = renderEmoji(post.html, post.emojis); + // handle mentions + if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT) + && md_nostack + && 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++; + length++; // skim the middle @ + while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++; - return post; + let mention = text.substring(index, index + length); - // let cache_post = get(cache)[post.id]; - // if (cache_post) { - // cache_post.set(post); - // } else { - // cache.update(cache => { - // cache[post.id] = writable(post); - // return cache; - // }); - // } + // attempt to resolve mention to a user + let user = await client.getUserByMention(mention); + if (user) { + const out = `` + + `` + + '@' + user.username + '@' + user.host + ""; + if (md_layer) md_layer.text += out; + else response += out; + } else { + response += mention; + } + index += mention.length; + continue; + } - // return get(cache)[post.id]; -} - -export function parseReactions(data) { - let reactions = []; - data.forEach(reaction_data => { - let reaction = { - count: reaction_data.count, - name: reaction_data.name, - me: reaction_data.me, - }; - if (reaction_data.url) reaction.url = reaction_data.url; - reactions.push(reaction); - }); - return reactions; + // handle links + if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT) + && md_nostack + && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g) + ) { + // get length of link + let length = text.substring(index).search(/\s|$/g); + let url = text.substring(index, index + length); + let out = `${url}`; + if (md_layer) md_layer.text += out; + else response += out; + index += length; + continue; + } + + // handle emojis + if (md_nostack && 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.getEmoji(emoji_name + '@' + this.user.host); + + index += length + 2; + + if (!emoji) { + let out = ':' + emoji_name + ':'; + if (md_layer) md_layer.text += out; + else response += out; + continue; + } + + let out = emoji.html; + if (md_layer) md_layer.text += out; + else response += out; + continue; + } + + // handle markdown + // TODO: handle misskey-flavoured markdown(?) + if (md_layer) { + // try to pop layer + if (sample.startsWith(md_layer.token)) { + index += md_layer.token.length; + let out = `<${md_layer.tag}>${md_layer.text}`; + if (md_layer.token === '```') + out = `
${md_layer.text}
`; + if (md_layer.parent) md_layer.parent.text += out; + else response += out; + md_layer = md_layer.parent; + } else { + md_layer.text += sample[0]; + index++; + } + } else if (md_nostack) { + // should we add a layer? + let pushed = false; + for (let i = 0; i < markdown_tokens.length; i++) { + let item = markdown_tokens[i]; + if (sample.startsWith(item.token)) { + let new_md_layer = { + token: item.token, + tag: item.tag, + text: "", + parent: md_layer, + }; + if (item.token === '```' || item.token === '`') new_md_layer.nostack = true; + md_layer = new_md_layer; + pushed = true; + index += md_layer.token.length; + break; + } + } + if (!pushed) { + response += sample[0]; + index++; + } + } + } + + // destroy the remaining stack + while (md_layer) { + let out = md_layer.token + md_layer.text; + if (md_layer.parent) md_layer.parent.text += out; + else response += out; + md_layer = md_layer.parent; + } + + return response; + } + */ } diff --git a/src/lib/stores/account.js b/src/lib/stores/account.js deleted file mode 100644 index 8361624..0000000 --- a/src/lib/stores/account.js +++ /dev/null @@ -1,4 +0,0 @@ -import { writable } from 'svelte/store'; - -export let account = writable(false); -export let logged_in = writable(false); diff --git a/src/lib/stores/user.js b/src/lib/stores/user.js new file mode 100644 index 0000000..fb9c2c4 --- /dev/null +++ b/src/lib/stores/user.js @@ -0,0 +1,22 @@ +import { client } from '$lib/client/client.js'; +import * as api from '$lib/client/api.js'; +import { get, writable } from 'svelte/store'; + +export let user = writable(0); +export let logged_in = writable(false); + +export async function getUser() { + // already known + if (get(user)) return get(user); + + // cannot provide- not logged in + if (!get(client).app || !get(client).app.token) return false; + + // logged in- attempt to retrieve using token + const data = await api.verifyCredentials(); + if (!data) return false; + + user.set(await api.parseUser(data)); + console.log(`Logged in as @${get(user).username}@${get(user).host}`); + return get(user); +} diff --git a/src/lib/timeline.js b/src/lib/timeline.js index ae8a5e3..0ef7b8f 100644 --- a/src/lib/timeline.js +++ b/src/lib/timeline.js @@ -1,10 +1,8 @@ -import * as api from '$lib/api.js'; -import { server } from '$lib/client/server.js'; -import { app } from '$lib/client/app.js'; +import { client } from '$lib/client/client.js'; import { get, writable } from 'svelte/store'; -import { parsePost } from '$lib/post.js'; +import { parsePost } from '$lib/client/api.js'; -export const timeline = writable([]); +export let timeline = writable([]); let loading = false; @@ -12,16 +10,9 @@ export async function getTimeline(clean) { if (loading) return; // no spamming!! loading = true; - let last_post = false; - if (!clean && get(timeline).length > 0) - last_post = get(timeline)[get(timeline).length - 1].id; - - const timeline_data = await api.getTimeline( - get(server).host, - get(app).token, - "home", - last_post - ); + let timeline_data; + if (clean || get(timeline).length === 0) timeline_data = await get(client).getTimeline() + else timeline_data = await get(client).getTimeline(get(timeline)[get(timeline).length - 1].id); if (!timeline_data) { console.error(`Failed to retrieve timeline.`); @@ -33,7 +24,7 @@ export async function getTimeline(clean) { for (let i in timeline_data) { const post_data = timeline_data[i]; - const post = await parsePost(post_data, 1); + const post = await parsePost(post_data, 1, false); if (!post) { if (post === null || post === undefined) { if (post_data.id) { diff --git a/src/lib/ui/LoginForm.svelte b/src/lib/ui/LoginForm.svelte index 13cfc99..8cf9b07 100644 --- a/src/lib/ui/LoginForm.svelte +++ b/src/lib/ui/LoginForm.svelte @@ -1,42 +1,37 @@ @@ -45,11 +40,11 @@

Welcome, fediverse user!

-

Please enter your server domain to log in.

+

Please enter your instance domain to log in.

- - {#if display_error} -

{display_error}

+ + {#if instance_url_error} +

{instance_url_error}

{/if}

diff --git a/src/lib/ui/Navigation.svelte b/src/lib/ui/Navigation.svelte index 6d514ef..26cbed5 100644 --- a/src/lib/ui/Navigation.svelte +++ b/src/lib/ui/Navigation.svelte @@ -1,17 +1,18 @@
- play_sound()}> + play_sound()}>
@@ -182,7 +183,7 @@ background-color: var(--bg-800); } - .server-header { + .instance-header { width: 100%; height: 172px; display: flex; @@ -195,7 +196,7 @@ background-image: linear-gradient(to top, var(--bg-800), var(--bg-600)); } - .server-icon { + .instance-icon { height: 50%; border-radius: 8px; } diff --git a/src/lib/ui/Notification.svelte b/src/lib/ui/Notification.svelte index ecf5b26..f16abec 100644 --- a/src/lib/ui/Notification.svelte +++ b/src/lib/ui/Notification.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/ui/post/Body.svelte b/src/lib/ui/post/Body.svelte index 7b06bc6..6bc85f7 100644 --- a/src/lib/ui/post/Body.svelte +++ b/src/lib/ui/post/Body.svelte @@ -1,6 +1,8 @@ @@ -20,22 +22,22 @@ {/if} {#if !post.warning || open_warned} - {#if post.html} - {@html post.html} + {#if post.text} + {@html rich_text} {/if} - {#if post.media && post.media.length > 0} -
- {#each post.media as media} -
- {#if ["image", "gifv", "gif"].includes(media.type)} - - {media.description} + {#if post.files && post.files.length > 0} +
+ {#each post.files as file} +
+ {#if ["image", "gifv", "gif"].includes(file.type)} + + {file.description} - {:else if media.type === "video"} + {:else if file.type === "video"} {/if}
diff --git a/src/lib/ui/post/BoostContext.svelte b/src/lib/ui/post/BoostContext.svelte index 8e2a97d..2194ef2 100644 --- a/src/lib/ui/post/BoostContext.svelte +++ b/src/lib/ui/post/BoostContext.svelte @@ -1,16 +1,17 @@
🔁 - - {@html post.account.rich_name} + + {@html parseEmojis(post.user.rich_name)} boosted this post. diff --git a/src/lib/ui/post/Post.svelte b/src/lib/ui/post/Post.svelte index c3f430a..15f01aa 100644 --- a/src/lib/ui/post/Post.svelte +++ b/src/lib/ui/post/Post.svelte @@ -1,4 +1,5 @@
diff --git a/src/lib/ui/post/PostHeader.svelte b/src/lib/ui/post/PostHeader.svelte index 3bb59b0..93b0949 100644 --- a/src/lib/ui/post/PostHeader.svelte +++ b/src/lib/ui/post/PostHeader.svelte @@ -1,5 +1,6 @@
- - + +
- {#await init()} + {#await ready}
just a moment...
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c13c9bd..05c2764 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,11 +1,13 @@
diff --git a/src/routes/post/[id]/+page.svelte b/src/routes/post/[id]/+page.svelte index 50593e0..e2e27aa 100644 --- a/src/routes/post/[id]/+page.svelte +++ b/src/routes/post/[id]/+page.svelte @@ -1,9 +1,7 @@