diff --git a/CHANGELOGS.md b/CHANGELOGS.md new file mode 100644 index 0000000..4c81497 --- /dev/null +++ b/CHANGELOGS.md @@ -0,0 +1,10 @@ +# Campfire v0.3.0 +- Added notifications view +- Many more background tweaks, fixes, and optimisations + +# Campfire v0.2.0 +- Complete UI overhaul (thanks mae!) +- Added light and dark themes +- Added ability to like and boost posts +- Added ability to view threads in context +- Many background tweaks, fixes, and optimisations diff --git a/package-lock.json b/package-lock.json index 2388e03..68c3b16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "campfire-client", - "version": "0.2.0_rev3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "campfire-client", - "version": "0.2.0_rev3", + "version": "0.3.0", "license": "GPL-3.0", "devDependencies": { "@poppanator/sveltekit-svg": "^4.2.1", diff --git a/package.json b/package.json index 80a43c1..301641e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "campfire-client", - "version": "0.2.0", + "version": "0.3.0", "description": "social media for the galaxy-wide-web! 🌌", "private": true, "type": "module", diff --git a/src/app.html b/src/app.html index ce47f4e..9f07942 100644 --- a/src/app.html +++ b/src/app.html @@ -12,14 +12,14 @@ - + - + %sveltekit.head% diff --git a/src/img/icons/quote.svg b/src/img/icons/quote.svg index 8e47670..98ce541 100644 --- a/src/img/icons/quote.svg +++ b/src/img/icons/quote.svg @@ -1,3 +1,4 @@ - + + diff --git a/src/lib/client/api.js b/src/lib/client/api.js index 27f0e0b..09e6514 100644 --- a/src/lib/client/api.js +++ b/src/lib/client/api.js @@ -1,8 +1,9 @@ -import { Client } from '../client/client.js'; +import { client } from '$lib/client/client.js'; +import { user } from '$lib/stores/user.js'; import { capabilities } from '../client/instance.js'; -import Post from '../post.js'; -import User from '../user/user.js'; -import Emoji from '../emoji.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) { @@ -31,25 +32,23 @@ export async function createApp(host) { } export function getOAuthUrl() { - let client = get(Client.get()); - return `https://${client.instance.host}/oauth/authorize` + - `?client_id=${client.app.id}` + + 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 client = get(Client.get()); let form = new FormData(); - form.append("client_id", client.app.id); - form.append("client_secret", client.app.secret); + 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://${client.instance.host}/oauth/token`, { + const res = await fetch(`https://${get(client).instance.host}/oauth/token`, { method: "POST", body: form, }) @@ -65,13 +64,12 @@ export async function getToken(code) { } export async function revokeToken() { - let client = get(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); + 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://${client.instance.host}/oauth/revoke`, { + const res = await fetch(`https://${get(client).instance.host}/oauth/revoke`, { method: "POST", body: form, }) @@ -85,34 +83,52 @@ export async function revokeToken() { } export async function verifyCredentials() { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/accounts/verify_credentials`; + let url = `https://${get(client).instance.host}/api/v1/accounts/verify_credentials`; const data = await fetch(url, { method: 'GET', - headers: { "Authorization": "Bearer " + client.app.token } + 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) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/timelines/home`; + 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 " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => res.json()); return data; } export async function getPost(post_id, ancestor_count) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}`; const data = await fetch(url, { method: 'GET', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -120,11 +136,10 @@ export async function getPost(post_id, ancestor_count) { } export async function getPostContext(post_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/context`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/context`; const data = await fetch(url, { method: 'GET', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -132,11 +147,10 @@ export async function getPostContext(post_id) { } export async function boostPost(post_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/reblog`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/reblog`; const data = await fetch(url, { method: 'POST', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -144,11 +158,10 @@ export async function boostPost(post_id) { } export async function unboostPost(post_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreblog`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreblog`; const data = await fetch(url, { method: 'POST', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -156,11 +169,10 @@ export async function unboostPost(post_id) { } export async function favouritePost(post_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/favourite`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/favourite`; const data = await fetch(url, { method: 'POST', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -168,11 +180,10 @@ export async function favouritePost(post_id) { } export async function unfavouritePost(post_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unfavourite`; + let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unfavourite`; const data = await fetch(url, { method: 'POST', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -185,11 +196,10 @@ export async function reactPost(post_id, shortcode) { // to the default like emote. // identical api calls on chuckya instances do not display // this behaviour. - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`; + 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 " + client.app.token } + headers: { "Authorization": "Bearer " + get(client).app.token } }).then(res => { return res.ok ? res.json() : false }); if (data === false) return false; @@ -197,26 +207,24 @@ export async function reactPost(post_id, shortcode) { } export async function unreactPost(post_id, shortcode) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(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 " + client.app.token } + 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, with_context) { - let client = get(Client.get()); +export async function parsePost(data, ancestor_count) { let post = new Post(); post.text = data.content; + post.html = data.content; post.reply = null; - if (!with_context && // ancestor replies are handled in full later - (data.in_reply_to_id || data.reply) && + 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); @@ -225,28 +233,8 @@ export async function parsePost(data, ancestor_count, with_context) { 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.replies = []; - if (with_context) { - const replies_data = await getPostContext(data.id); - if (replies_data) { - // posts this is replying to - if (replies_data.ancestors) { - let head = post; - while (replies_data.ancestors.length > 0) { - head.reply = await parsePost(replies_data.ancestors.pop(), 0, false); - head = head.reply; - } - } - // posts in reply to this - if (replies_data.descendants) { - for (let i in replies_data.descendants) { - post.replies.push(await parsePost(replies_data.descendants[i], 0, false)); - } - } - } - } + post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null; post.id = data.id; post.created_at = new Date(data.created_at); @@ -275,7 +263,7 @@ export async function parsePost(data, ancestor_count, with_context) { }); } - if (data.reactions && client.instance.capabilities.includes(capabilities.REACTIONS)) { + if (data.reactions && get(client).instance.capabilities.includes(capabilities.REACTIONS)) { post.reactions = parseReactions(data.reactions); } return post; @@ -286,15 +274,14 @@ export async function parseUser(data) { console.error("Attempted to parse user data but no data was provided"); return null; } - let client = get(Client.get()); - let user = await client.getCacheUser(data.id); + 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; + user.nickname = data.display_name.trim(); user.username = data.username; user.avatar_url = data.avatar; user.url = data.url; @@ -302,7 +289,7 @@ export async function parseUser(data) { if (data.acct.includes('@')) user.host = data.acct.split('@')[1]; else - user.host = client.instance.host; + user.host = get(client).instance.host; user.emojis = []; data.emojis.forEach(emoji_data => { @@ -312,12 +299,11 @@ export async function parseUser(data) { user.emojis.push(parseEmoji(emoji_data)); }); - client.putCacheUser(user); + get(client).putCacheUser(user); return user; } export function parseReactions(data) { - let client = get(Client.get()); let reactions = []; data.forEach(reaction_data => { let reaction = { @@ -338,27 +324,16 @@ export function parseEmoji(data) { data.host, data.url, ); - get(Client.get()).putCacheEmoji(emoji); + get(client).putCacheEmoji(emoji); return emoji; } export async function getUser(user_id) { - let client = get(Client.get()); - let url = `https://${client.instance.host}/api/v1/accounts/${user_id}`; + let url = `https://${get(client).instance.host}/api/v1/accounts/${user_id}`; const data = await fetch(url, { method: 'GET', - headers: { "Authorization": "Bearer " + client.app.token } + headers: { "Authorization": "Bearer " + get(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; + return data; } diff --git a/src/lib/client/client.js b/src/lib/client/client.js index f4e3788..e6d79ac 100644 --- a/src/lib/client/client.js +++ b/src/lib/client/client.js @@ -1,8 +1,10 @@ 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'; -let client = writable(false); +export const client = writable(false); const save_name = "campfire"; @@ -22,15 +24,6 @@ export class Client { }; } - static get() { - let current = get(client); - if (current && current.app) return client; - let new_client = new Client(); - new_client.load(); - client.set(new_client); - return client; - } - async init(host) { if (host.startsWith("https://")) host = host.substring(8); const url = `https://${host}/api/v1/instance`; @@ -76,30 +69,34 @@ export class Client { console.error("Failed to obtain access token"); return false; } - this.app.token = token; - client.set(this); + return token; } async revokeToken() { return await api.revokeToken(); } - async verifyCredentials() { + async getClientUser() { + // already known if (this.user) return this.user; + + // cannot provide- not logged in if (!this.app || !this.app.token) { - this.user = false; return false; } + + // logged in- attempt to retrieve using token const data = await api.verifyCredentials(); if (!data) { - this.user = false; return false; } - await client.update(async c => { - c.user = await api.parseUser(data); - console.log(`Logged in as @${c.user.username}@${c.user.host}`); - }); - return this.user; + const user = await api.parseUser(data); + console.log(`Logged in as @${user.username}@${user.host}`); + return user; + } + + async getNotifications(since_id, limit, types) { + return await api.getNotifications(since_id, limit, types); } async getTimeline(last_post_id) { @@ -110,6 +107,10 @@ export class Client { 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); } @@ -166,6 +167,10 @@ export class Client { 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({ @@ -174,6 +179,7 @@ export class Client { host: this.instance.host, version: this.instance.version, }, + last_read_notif_id: get(last_read_notif_id), app: this.app, })); } @@ -188,6 +194,7 @@ export class Client { 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; @@ -199,7 +206,8 @@ export class Client { console.warn("Failed to log out correctly; ditching the old tokens anyways."); } localStorage.removeItem(save_name); - client.set(false); + logged_in.set(false); + client.set(new Client()); console.log("Logged out successfully."); } } diff --git a/src/lib/emoji.js b/src/lib/emoji.js index 4fdd161..89df2d1 100644 --- a/src/lib/emoji.js +++ b/src/lib/emoji.js @@ -1,4 +1,4 @@ -import { Client } from './client/client.js'; +import { client } from './client/client.js'; import { get } from 'svelte/store'; export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g; @@ -33,7 +33,7 @@ export function parseText(text, host) { 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.get()).getEmoji(emoji_name + '@' + host); + let emoji = get(client).getEmoji(emoji_name + '@' + host); if (emoji) { return text.substring(0, index) + emoji.html + @@ -46,7 +46,7 @@ export function parseText(text, 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.get()).getEmoji(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 new file mode 100644 index 0000000..bbdc69f --- /dev/null +++ b/src/lib/notifications.js @@ -0,0 +1,40 @@ +import { client } from '$lib/client/client.js'; +import * as api from '$lib/client/api.js'; +import { get, writable } from 'svelte/store'; + +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() { + if (loading) return; // no spamming!! + loading = true; + + 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]); + } + last_read_notif_id.set(data[0].id); + unread_notif_count.set(0); + get(client).save(); + loading = 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 5858199..0ef7b8f 100644 --- a/src/lib/timeline.js +++ b/src/lib/timeline.js @@ -1,8 +1,8 @@ -import { Client } from '$lib/client/client.js'; +import { client } from '$lib/client/client.js'; import { get, writable } from 'svelte/store'; import { parsePost } from '$lib/client/api.js'; -export let posts = writable([]); +export let timeline = writable([]); let loading = false; @@ -10,11 +10,9 @@ export async function getTimeline(clean) { if (loading) return; // no spamming!! loading = true; - let client = get(Client.get()); - let timeline_data; - if (clean || get(posts).length === 0) timeline_data = await client.getTimeline() - else timeline_data = await client.getTimeline(get(posts)[get(posts).length - 1].id); + 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.`); @@ -22,7 +20,7 @@ export async function getTimeline(clean) { return; } - if (clean) posts.set([]); + if (clean) timeline.set([]); for (let i in timeline_data) { const post_data = timeline_data[i]; @@ -38,7 +36,7 @@ export async function getTimeline(clean) { } continue; } - posts.update(current => [...current, post]); + timeline.update(current => [...current, post]); } loading = false; } diff --git a/src/lib/ui/Button.svelte b/src/lib/ui/Button.svelte index 702d3fa..ea68fc4 100644 --- a/src/lib/ui/Button.svelte +++ b/src/lib/ui/Button.svelte @@ -1,6 +1,8 @@ + + + + +
{#if posts.length <= 0}
getting the feed...
{/if} - {#each $posts as post} + {#each posts as post} {/each}
@@ -29,6 +29,7 @@ diff --git a/src/lib/ui/Navigation.svelte b/src/lib/ui/Navigation.svelte index a513649..c4ce6e5 100644 --- a/src/lib/ui/Navigation.svelte +++ b/src/lib/ui/Navigation.svelte @@ -2,11 +2,16 @@ import Logo from '$lib/../img/campfire-logo.svg'; import Button from './Button.svelte'; import Feed from './Feed.svelte'; - import { Client } from '$lib/client/client.js'; + import { client } from '$lib/client/client.js'; + import { user } from '$lib/stores/user.js'; import { play_sound } from '$lib/sound.js'; import { getTimeline } from '$lib/timeline.js'; + import { getNotifications } from '$lib/notifications.js'; import { goto } from '$app/navigation'; + import { page } from '$app/stores'; import { get } from 'svelte/store'; + import { logged_in } from '$lib/stores/user.js'; + import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js'; import TimelineIcon from '../../img/icons/timeline.svg'; import NotificationsIcon from '../../img/icons/notifications.svg'; @@ -21,61 +26,70 @@ import LogoutIcon from '../../img/icons/logout.svg'; const VERSION = APP_VERSION; - - let client = false; - Client.get().subscribe(c => { - client = c; - }); - let notification_count = 0; - if (notification_count > 99) notification_count = "99+"; - - function goTimeline() { - if (location.pathname === "/") { - getTimeline(true); - window.scrollTo({ - top: 0, - behavior: "smooth" - }); - return; + function handle_btn(name) { + if (!get(logged_in)) return; + let route; + switch (name) { + case "timeline": + route = "/"; + getTimeline(true); + break; + case "notifications": + route = "/notifications"; + getNotifications(); + break; + case "explore": + case "lists": + case "favourites": + case "bookmarks": + case "hashtags": + default: + return; } - goto("/"); + if (!route) return; + window.scrollTo({ + top: 0, + behavior: "smooth" + }); + goto(route); } async function log_out() { if (!confirm("This will log you out. Are you sure?")) return; - await get(Client.get()).logout(); + await get(client).logout(); goto("/"); }